diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 386989022..da85536c4 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 4cceb9225..267479eb8 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/ATTRIBUTION.md b/ATTRIBUTION.md index 46b789a86..cfcdbf067 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l | Asset | Author | Source | Licence | Use | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | + +## Arabic interface catalog + +| Path | Contributor | Source | Licence | Review | +|---|---|---|---|---| +| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending | diff --git a/CLAUDE.md b/CLAUDE.md index b2384311a..f61412ba7 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 413011ab4..8901fd6cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,7 +253,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -942,12 +942,94 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -972,7 +1054,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "wayland-client", ] @@ -986,6 +1068,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1478,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1545,9 +1636,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.2" +version = "0.12.1" dependencies = [ "arboard", + "craft-menu", "craft-update", "craft-window", "eframe", @@ -1573,7 +1665,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1587,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1598,12 +1690,13 @@ dependencies = [ "postcard", "serde", "serde_json", + "sha2", "thiserror 2.0.21", ] [[package]] name = "figcraft-collab-net" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1626,7 +1719,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1635,7 +1728,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1647,7 +1740,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1679,7 +1772,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1695,7 +1788,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1703,7 +1796,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1711,7 +1804,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1727,7 +1820,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1736,7 +1829,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1753,7 +1846,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "serde", @@ -1766,7 +1859,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "figcraft-color", @@ -1784,7 +1877,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "ctrlc", @@ -1808,7 +1901,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1824,7 +1917,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.2" +version = "0.12.1" dependencies = [ "core-text", "figcraft-doc", @@ -1840,10 +1933,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 +1961,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1881,7 +1975,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "eframe", @@ -1913,6 +2007,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 +2504,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 +2517,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 +2633,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 +2655,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2711,6 +2910,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2731,7 +2940,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 +3053,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -2920,6 +3132,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -3418,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3684,6 +3914,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4090,7 +4322,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4147,7 +4379,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4681,6 +4913,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4698,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4855,6 +5099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5165,6 +5410,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -5814,7 +6065,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6152,11 +6403,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 +6495,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-engine", "serde_json", @@ -6424,8 +6684,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6434,6 +6697,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 f6fca285a..2bf5da744 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,18 @@ figcraft-plugin = { path = "crates/plugin" } # craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button +# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work). +craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -100,6 +105,9 @@ webbrowser = "1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # Style lints from newer toolchains that fight index-heavy pixel code. needless_range_loop = "allow" @@ -128,7 +136,7 @@ lto = "thin" codegen-units = 4 # The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size: -# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB). +# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB). # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" @@ -160,4 +168,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3b..129c21de1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people. | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | -| Arabic | ar | 0% | none | +| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | @@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people. Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -127,11 +127,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)). +4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log +- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. +- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. +- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. @@ -348,6 +351,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | +| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. | +| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. | +| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1e440a32c..1e58f7c2d 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 f4703f20d..5dd45435b 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.rs b/apps/figcraft-server/src/store.rs index 1ea420a54..90fce92ba 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -52,6 +52,14 @@ use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; +mod backend; +mod files; +mod journal; +mod library; +mod links; + +pub use library::library_name; + pub const LOCK: &str = "LOCK"; pub const KEY: &str = "server.key"; pub const FILES: &str = "files"; @@ -541,847 +549,6 @@ impl Store { people: open.map_or(0, |o| o.people), } } - - /// Keep a document (its snapshot) as a new file with an edit link and a view link. - pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { - self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) - } - - /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's - /// caps it gets, and the files and bytes kept by the files published from addresses in it. An - /// address that doesn't parse counts as itself only, ×1, by its exact text. - fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { - let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); - let kept: Vec<(&str, Vec<_>, u64)> = self - .files - .iter() - .filter_map(|(id, e)| { - let from = e.meta.ip.as_deref()?; - Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) - }) - .collect(); - let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); - let blocks = blocks_of(ip); - if blocks.is_empty() { - let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); - return vec![(1, files, bytes)]; - } - blocks - .into_iter() - .map(|(block, times)| { - let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); - (times, files, bytes) - }) - .collect() - } - - /// Keep a document published without the key from `ip`, within the caps on what one address - /// and everyone may keep: the file, its links and its owner token. - pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { - let h = &self.cfg.hosting; - let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); - if !open { - return Err(KEY_ONLY.into()); - } - if !self.abuse.allow(ip, Action::Publish, Instant::now()) { - return Err("too many files published from your address lately: try again in an hour".into()); - } - let blocks = self.per_block(&ip.to_string()); - for &(times, files, _) in &blocks { - if files >= max_files.saturating_mul(times as usize) { - return Err(format!( - "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" - )); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - let fits = |n: u64| { - for &(times, _, used) in &blocks { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); - } - } - if used_all.saturating_add(n) > max_all { - return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); - } - Ok(()) - }; - let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; - Ok((info, edit, view, owner.unwrap_or_default())) - } - - /// Keep a document as a new file: published without the key from `ip` (it gets an owner - /// token), else by the operator. `fits` checks its size against the caps that apply. - fn keep( - &mut self, - name: &str, - snapshot: &[Change], - ip: Option, - fits: &dyn Fn(u64) -> Result<(), String>, - ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { - if self.files.len() >= self.cfg.max_files { - return Err(format!("the server keeps at most {} files", self.cfg.max_files)); - } - if let Some(why) = self.low_disk() { - return Err(why); - } - let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; - if replica.document().node_count() > self.cfg.core.max_nodes { - return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); - } - let mut doc = replica.document().clone(); - let name = match clean_name(name) { - n if n.is_empty() => doc.name.clone(), - n => n, - }; - doc.name = name.clone(); - let server = Server::new(doc, "", self.cfg.core); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(1, &state)?; - if bytes.len() as u64 > self.cfg.max_file_bytes { - return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); - } - fits(bytes.len() as u64)?; - let id = loop { - let id = token::file_id()?; - if !self.files.contains_key(&id) && !self.dir(&id).exists() { - break id; - } - }; - let t = now(); - let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; - let (view, view_token) = new_link(Access::View, "Published (view)", t)?; - let owner = ip.map(|_| token::secret()).transpose()?; - let meta = - Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; - let dir = self.dir(&id); - let written = fs::create_dir_all(&dir) - .map_err(|e| e.to_string()) - .and_then(|()| { - disk::sync_dir(Some(&self.root.join(FILES))); - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) - }) - .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) - .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) - // Last: a folder without its description is an upload cut short. - .and_then(|()| write_json(&dir.join(META), &meta)); - if let Err(e) = written { - let _ = fs::remove_dir_all(&dir); - return Err(format!("couldn't keep the file: {e}")); - } - let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; - self.files.insert(id.clone(), entry); - let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; - match ip { - Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), - None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), - } - Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) - } - - /// The files kept, with what the operator moderates by. - pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files - .iter() - .map(|(id, e)| FileRow { - info: self.info(id, e, open(id)), - opened: e.meta.opened, - expires: self.expires(&e.meta), - open: e.meta.owner.is_some(), - ip: e.meta.ip.clone().unwrap_or_default(), - reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), - }) - .collect() - } - - /// Owner token `owner` manages file `file` (one published without the key). - fn owns(&self, file: &str, owner: &str) -> bool { - let presented = hash(owner); - self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) - } - - /// What became of the files these `(file id, owner token)` pairs own: each one's name, links - /// and expiry, or `None` when that token manages no file of that id here (which doesn't say - /// whether the id exists). Asking opens nothing: it doesn't count as using a file. - pub fn owned(&self, files: &[(String, String)]) -> AdminReply { - if files.len() > MAX_OWNED { - return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; - } - let one = |(file, owner): &(String, String)| { - let e = self.files.get(file).filter(|_| self.owns(file, owner))?; - Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) - }; - AdminReply::Owned(files.iter().map(one).collect()) - } - - /// Rename a file (its listing; the document inside keeps its own name). - pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { - let name = clean_name(name); - if name.is_empty() { - return Err("give the file a name".into()); - } - let path = self.dir(file).join(META); - let entry = self.entry(file)?; - let before = std::mem::replace(&mut entry.meta.name, name); - if let Err(e) = write_json(&path, &entry.meta) { - entry.meta.name = before; - return Err(format!("couldn't rename the file: {e}")); - } - Ok(()) - } - - /// Delete the files published without the key that nobody opened for the idle time (not - /// those open now); their ids. - pub fn sweep(&mut self) -> Vec { - let t = now(); - self.next_sweep = t.saturating_add(SWEEP_EVERY); - let due: Vec = - self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); - for id in &due { - match self.remove(id) { - Ok(()) => log::info!("event=expired file={id}"), - Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), - } - } - due - } - - /// Make a file look unused for `secs` more (tests: expiry without waiting). - #[cfg(test)] - pub fn age(&mut self, file: &str, secs: u64) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - let m = &mut e.meta; - (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); - let _ = write_json(&path, m); - } - } - - /// Make an open file's log refuse writes (tests: a full or failing disk). - #[cfg(test)] - 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) - { - log.file = f; - } - } - - /// Note that a file was opened now (it is used: it doesn't expire). - fn touch(&mut self, file: &str) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - e.meta.opened = now(); - if let Err(err) = write_json(&path, &e.meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - } - - /// What the server says about its hosting (with `file`'s expiry). - fn host_info(&self, file: Option<&str>) -> HostInfo { - let h = &self.cfg.hosting; - let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); - HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } - } - - /// Block or unblock an address (an IPv6 address: its /64); the address as kept. - pub fn block(&mut self, ip: &str, on: bool) -> Result { - let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); - if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { - return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); - } - let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; - if changed { - let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); - write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; - log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); - } - Ok(ip) - } - - /// The blocked addresses. - pub fn blocked(&self) -> Vec { - self.blocked.iter().map(IpAddr::to_string).collect() - } - - /// The reports kept, oldest first. - pub fn reports(&self) -> &[ReportInfo] { - &self.reports - } - - /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). - pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason = clean_reason(reason); - if reason.is_empty() { - return Err("say what is wrong with the file".into()); - } - let name = self.entry(file)?.meta.name.clone(); - if !self.abuse.allow(ip, Action::Report, Instant::now()) { - return Err("too many reports from your address lately: try again in an hour".into()); - } - self.reports.push(ReportInfo { - at: now(), - file: file.to_string(), - name, - link: link.unwrap_or_default().to_string(), - ip: ip.to_string(), - reason, - }); - if self.reports.len() > MAX_REPORTS { - let extra = self.reports.len() - MAX_REPORTS; - self.reports.drain(..extra); - } - write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; - log::info!("event=report file={file} ip={ip}"); - Ok(()) - } - - /// The files kept. - pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() - } - - /// A file's links. - pub fn links(&mut self, file: &str) -> Result, String> { - Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) - } - - /// A new link to a file. - pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { - let max = self.cfg.max_links; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - if entry.links.len() >= max { - return Err(format!("a file has at most {max} links: revoke one first")); - } - let (link, token) = new_link(access, &clean_name(label), now())?; - entry.links.push(link.clone()); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.pop(); - return Err(format!("couldn't keep the link: {e}")); - } - Ok(NewLink { link: link.info(file), token }) - } - - /// The link of `entry` whose token `token` is (every link is compared, in constant time, so - /// timing doesn't tell which one matched). - fn link_of(entry: &Entry, token: &str) -> Option { - let presented = hash(token); - let mut found = None; - for (i, l) in entry.links.iter().enumerate() { - if same_secret(&presented, &l.hash) { - found = Some(i); - } - } - found - } - - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); - let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); - self.gone_codes.drain(..extra); - } - - /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). - fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { - let mut found = None; - for (id, e) in &self.files { - for (i, l) in e.links.iter().enumerate() { - if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { - found = Some((id.clone(), i, token)); - } - } - } - found - } - - /// A new join code for the link of `file` whose token is `token` (it takes the place of the - /// link's earlier code): the code, shown this once. - pub fn set_code(&mut self, file: &str, token: &str) -> Result { - let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; - // 45 random bits: one already in use is all but impossible, and never handed out twice. - let (code, key) = loop { - let code = codes::new_code()?; - let key = codes::Key::new(&code); - if self.find_code(&key).is_none() { - break (code, key); - } - }; - let sealed = key.seal(token)?; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let link = entry.links.get_mut(at).ok_or("that file has no such link")?; - let before = link.code.replace(sealed); - let id = link.id.clone(); - if let Err(e) = write_json(&path, &entry.links) { - if let Some(l) = entry.links.get_mut(at) { - l.code = before; - } - return Err(format!("couldn't keep the join code: {e}")); - } - self.forget_codes(before); - log::info!("event=code file={file} link={id}"); - Ok(code) - } - - /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against - /// the address (see `abuse`), and nothing is looked up for an address that is locked out. - pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { - let now = Instant::now(); - if let Some(left) = self.abuse.code_locked(ip, now) { - return Err(format!( - "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", - left.as_secs() / 60 + 1 - )); - } - if !self.abuse.allow(ip, Action::Code, now) { - return Err("Too many codes tried from your address: wait a minute and try again".into()); - } - if !self.abuse.codes_open(now) { - return Err(CODES_BUSY.into()); - } - // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). - let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); - let found = key.as_ref().and_then(|k| self.find_code(k)); - self.abuse.code_tried(ip, found.is_some(), now); - match found { - Some((file, _, token)) => { - log::info!("event=code_used file={file} ip={ip}"); - Ok((file, token)) - } - None => { - let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); - Err(if gone { CODE_GONE } else { NO_CODE }.into()) - } - } - } - - /// Revoke a link. - pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; - let gone = entry.links.remove(at); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.insert(at, gone); - return Err(format!("couldn't revoke the link: {e}")); - } - log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); - Ok(()) - } - - /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no - /// longer served either way). - pub fn remove(&mut self, file: &str) -> Result<(), String> { - // Closed first: an open log would keep the folder from being deleted on Windows. - let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); - drop(entry); - let dir = self.dir(file); - fs::remove_dir_all(&dir) - .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; - log::info!("removed file {file}"); - Ok(()) - } - - /// A file's team library for `ip`, unless its version is `have`; within what one address and - /// everyone may download (the size is known before anything is read). - fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); - let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; - if have == Some(lib.version) { - return Ok(LibraryReply::Unchanged { version: lib.version }); - } - let path = library_file(&dir, lib.version); - let size = fs::metadata(&path).map_or(0, |m| m.len()); - let now = Instant::now(); - if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { - return Err(BUSY_DOWNLOADS.into()); - } - Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) - } - - /// Keep `archive` as a file's team library: it must read as a library (with the limits files - /// using it read it with), be the file's library if it has one, and be a newer version. - // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); - // move it to a worker if large libraries make that noticeable. - pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { - let max = self.cfg.max_file_bytes; - if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { - return Err(NO_PUBLIC_LIBRARY.into()); - } - if archive.len() as u64 > max { - return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); - } - // Everything it unpacks to counts against the same cap, so a small archive can't expand - // into a large library in memory. - let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { - Ok(Some(lib)) => lib, - Ok(None) => return Err("that isn't a published library".into()), - Err(e) => return Err(format!("the library can't be read: {e}")), - }; - if lib.doc.node_count() > self.cfg.core.max_nodes { - return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); - } - let m = &lib.manifest; - let dir = self.dir(file); - let entry = self.entry(file)?; - if let Some(old) = &entry.meta.library { - if old.id != m.id { - return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); - } - if m.version <= old.version { - return Err(format!("version {} is on the server already: publish again to follow it", old.version)); - } - // A jump would let a client use up the numbers and leave no newer version to publish. - if m.version > old.version.saturating_add(1) { - return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); - } - } else if m.version > MAX_FIRST_LIBRARY_VERSION { - return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); - } - // Each version has a file of its own, written before the description names it: a failed - // description write or a crash in between leaves the previous version served whole, never - // the new bytes under the old number. - let path = dir.join(library_name(m.version)); - disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; - let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); - if let Err(e) = write_json(&dir.join(META), &entry.meta) { - // Never served (the description doesn't name it); the next publish of it replaces it. - if let Err(r) = fs::remove_file(&path) { - log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); - } - entry.meta.library = before; - return Err(format!("couldn't keep the library: {e}")); - } - // The previous version isn't served any more. - if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { - log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); - } - log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); - Ok(m.version) - } - - /// A library's usage reports in memory (read from disk the first time; a damaged list starts - /// over: it is only statistics). `None` when the file has no library. - fn usage_of(&mut self, file: &str) -> Result, String> { - if self.entry(file)?.meta.library.is_none() { - return Ok(None); - } - if !self.usage.contains_key(file) { - let path = self.dir(file).join(USAGE); - let list = if path.exists() { - disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() - } else { - Vec::new() - }; - self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); - } - Ok(self.usage.get_mut(file)) - } - - /// The usage reports kept for a file's library (none when it has no library). - pub fn library_usage(&mut self, file: &str) -> Result, String> { - Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) - } - - /// Keep how one file uses a file's library (in memory; written when it changed, at most every - /// [`USAGE_WRITE_EVERY`] seconds). - pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { - let name = clean_name(&usage.file); - if name.is_empty() { - return Ok(()); - } - let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - components.sort(); - components.dedup_by_key(|c| c.0); - components.truncate(MAX_REPORTED); - let Some(u) = self.usage_of(file)? else { return Ok(()) }; - match u.list.iter_mut().find(|f| f.usage.file == name) { - Some(f) if f.usage.components == components => return Ok(()), - Some(f) => { - f.usage.components = components; - f.updated = now(); - } - None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), - } - if u.list.len() > MAX_USERS { - u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); - u.list.truncate(MAX_USERS); - } - u.dirty = true; - if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { - self.write_usage(false); - } - Ok(()) - } - - /// Write the usage reports that changed (`all`: whenever they were last written). - fn write_usage(&mut self, all: bool) { - let t = now(); - let root = self.root.join(FILES); - for (file, u) in &mut self.usage { - if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { - if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { - log::warn!("file {file}: library usage couldn't be kept: {e}"); - } - (u.dirty, u.written) = (false, t); - } - } - } - - /// Whether `file` has room for about `n` more bytes: its cap, and for a file published - /// without the key its address's share and everyone's. A log that took it over its cap is - /// compacted first (the state may be smaller than its history). - fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { - if let Some(why) = self.low_disk() { - return Err(why); - } - let max = self.cfg.max_file_bytes; - let over = |s: &Store| s.bytes(file).saturating_add(n) > max; - // A compaction to make room at most once a minute per file (it rewrites the whole file). - let t = now(); - let may_compact = self.files.get(file).is_some_and(|e| { - t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) - }); - if over(self) && may_compact { - if let Some(e) = self.files.get_mut(file) { - e.forced = t; - } - self.compact(file, server); - } - if over(self) { - return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); - } - let h = &self.cfg.hosting; - let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; - let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - for (times, _, used) in self.per_block(&ip) { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - if used_all.saturating_add(n) > max_all { - return Err("this server is full: that change wasn't kept".into()); - } - Ok(()) - } - - /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). - fn load(&mut self, file: &str) -> Result { - self.touch(file); - let dir = self.dir(file); - let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; - let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; - let mut issues = Vec::new(); - let mut fresh_log = true; - let mut kept = 0; - let log_path = dir.join(LOG); - // A log that's there but can't be read (an IO error, or over a lowered cap) may hold - // confirmed edits: the file isn't opened, and the log is left as it is for another try. - let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { - Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), - r => r, - }; - // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). - let read = match read.map(|b| disk::read_log(&b)) { - Ok(Err(why)) => { - let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); - fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; - Err(format!("{why}; it was moved aside to {}", aside.display())) - } - Ok(Ok(log)) => Ok(log), - Err(why) => Err(why), - }; - match read { - Ok(log) if log.generation == generation => { - fresh_log = false; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); - fresh_log = true; - break; - } - kept += 1; - } - if let Some(why) = log.damaged { - issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); - fresh_log = true; - } - } - // From before a compaction that was cut short, the snapshot has it all; or a generation - // field damaged, which no checksum covers. Replaying tells them apart without trusting - // it: entries the snapshot holds are skipped, so only edits it lacks change anything. - Ok(log) => { - let before = server.seq(); - let mut stopped = None; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - stopped = Some(e); - break; - } - } - let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); - if recovered > 0 { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ - {recovered} changes the snapshot didn't; they were recovered" - )); - } - if let Some(e) = stopped { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ - making sense after {recovered} newer changes ({e}); the rest was dropped" - )); - } - } - Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), - } - for i in issues { - log::warn!("{i}"); - self.note(i); - } - server.journaling(true); - let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); - let log = match (fresh_log, file_handle) { - (false, Ok(f)) => { - let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); - // Replayed entries make it worth compacting once everyone has left. - Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } - } - // A new snapshot and log of the next generation, so appends start clean. - _ => { - let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; - self.rewrite(file, next, &server).map_err(|e| e.why)? - } - }; - self.entry(file)?.log = Some(log); - Ok(server) - } - - /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the - /// document (recent batches for reconnecting clients, deletions), which may take it over. - fn snapshot_cap(&self) -> u64 { - self.cfg.max_file_bytes.saturating_mul(2) - } - - /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { - let dir = self.dir(file); - let kept = |why: String| RewriteError { why, snapshot_replaced: false }; - let state = server.state().map_err(|e| kept(e.to_string()))?; - let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; - // Never replace a readable snapshot with one that can't be opened again: the old snapshot - // and log still hold everything. - if bytes.len() as u64 > self.snapshot_cap() { - return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); - } - if bytes.len() as u64 > self.cfg.max_file_bytes { - log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); - } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; - // From here the old log no longer goes with the snapshot on disk. - let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; - if let Ok(e) = self.entry(file) { - e.bytes = (bytes.len() + LOG_HEADER) as u64; - e.meta.seq = server.seq(); - let meta = e.meta.clone(); - if let Err(err) = write_json(&dir.join(META), &meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) - } - - /// Compactions so far. - pub fn compactions(&self) -> u64 { - self.compactions - } - - /// Why nothing more may be written when the data directory's disk is nearly full (less free - /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space - /// can't be told, nothing is refused. - fn low_disk(&mut self) -> Option { - let now = Instant::now(); - let free = match self.free { - Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, - _ => { - let free = disk::free_bytes(&self.root); - self.free = Some((now, free)); - free - } - }; - free.filter(|f| *f < self.cfg.min_free_bytes) - .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) - } - - /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. - fn note(&mut self, problem: String) { - if self.problems.len() >= MAX_PROBLEMS { - self.problems.remove(0); - } - self.problems.push(problem); - } - - /// Compact an open file: its state as the next snapshot, an empty log. - fn compact(&mut self, file: &str, server: &Server) { - self.compactions += 1; - let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - let Some(next) = generation.checked_add(1) else { - log::error!("file {file}: compaction failed: the file's generation can't go any higher"); - return; - }; - match self.rewrite(file, next, server) { - Ok(log) => { - if let Ok(e) = self.entry(file) { - e.log = Some(log); - } - log::debug!("compacted file {file}"); - } - // The new snapshot holds everything, but a restart would drop whatever is appended to - // the old log now (its generation no longer matches): the file closes, the hub hears - // it at the next flush, and it opens again from the new snapshot. - Err(RewriteError { why, snapshot_replaced: true }) => { - self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); - log::error!("file {file}: compaction was cut short: {why}"); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - self.cut_short.push(file.to_string()); - } - // The old snapshot and log still hold everything: carry on appending. - Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), - } - } - - /// Write and sync what's pending in every open log; the files whose log couldn't be written. - /// Those are closed here (their pending records dropped: nobody was told of them) and open - /// again from what is on disk. - fn flush_all(&mut self) -> Vec { - let mut failed = std::mem::take(&mut self.cut_short); - for (id, e) in &mut self.files { - if let Some(log) = e.log.as_mut() { - match flush_log(id, log) { - Ok(n) => e.bytes += n, - Err(()) => { - e.log = None; - failed.push(id.clone()); - } - } - } - } - failed - } } /// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced @@ -1391,38 +558,6 @@ struct RewriteError { snapshot_replaced: bool, } -/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log -/// can't be trusted to hold them). -fn flush_log(id: &str, log: &mut Log) -> Result { - if log.pending.is_empty() { - return Ok(0); - } - match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { - Ok(()) => { - let n = log.pending.len() as u64; - log.bytes += n; - log.pending.clear(); - Ok(n) - } - Err(e) => { - log::error!("file {id}: couldn't write its log: {e}"); - Err(()) - } - } -} - -/// The name of the file holding version `version` of a file's team library. -pub fn library_name(version: u64) -> String { - format!("library-{version}.bin") -} - -/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a -/// version published before each version had one. -fn library_file(dir: &Path, version: u64) -> PathBuf { - let path = dir.join(library_name(version)); - if path.exists() { path } else { dir.join(LIBRARY) } -} - fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1433,243 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), let token = token::secret()?; Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } - -impl Backend for Store { - fn admit(&mut self, file: &str, token: &str) -> Result { - let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); - link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) - .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) - } - - fn open(&mut self, file: &str) -> Result { - self.load(file).map_err(|e| { - let why = format!("file {file} can't be opened: {e}"); - log::error!("{why}"); - self.note(why); - "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() - }) - } - - fn changed(&mut self, file: &str, server: &mut Server) { - let journal = server.take_journal(); - if journal.is_empty() { - return; - } - let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); - let Some(e) = self.files.get_mut(file) else { return }; - let Some(log) = e.log.as_mut() else { return }; - for j in &journal { - match disk::record(j) { - Ok(r) => log.pending.extend(r), - Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), - } - } - log.entries += journal.len(); - log.dirty = true; - e.meta.updated = now(); - if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { - // The snapshot holds what's pending too. - self.compact(file, server); - } - } - - fn flush(&mut self) -> Vec { - self.flush_all() - } - - fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { - self.room_for(file, server, bytes as u64) - } - - fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let compact = self.files.get_mut(file).is_some_and(|entry| { - let snapshot_seq = entry.meta.seq; - entry.log.as_mut().is_some_and(|log| { - // Issued client ids and refusals are durable in the log; only edits need a - // new image-bearing snapshot. Still try a snapshot if syncing the log failed. - flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) - }) - }); - if compact { - self.compact(file, server); - } - // It closes anyway, with its state in the snapshot and durable log. - self.cut_short.retain(|f| f != file); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - // Everyone just left: idle time counts from now. - self.touch(file); - true - } - - fn stop(&mut self, open: Vec<(&str, &mut Server)>) { - self.write_usage(true); - self.flush_all(); - for (file, server) in open { - if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { - self.compact(file, server); - } - } - } - - fn manage(&mut self, key: &str) -> Result<(), String> { - match same_secret(key, &self.key) { - true => Ok(()), - false if self.cfg.hosting.open => { - Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) - } - false => Err("wrong server key".into()), - } - } - - fn connect(&mut self, ip: IpAddr) -> Result<(), String> { - if now() >= self.next_sweep { - self.sweep(); - self.write_usage(false); - } - if self.blocked.contains(&ip) { - return Err("this server doesn't accept connections from your address".into()); - } - if !self.abuse.allow(ip, Action::Connect, Instant::now()) { - return Err("too many connections from your address lately: wait a minute and try again".into()); - } - Ok(()) - } - - fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { - // Cursors and cursor chat (many people behind one address each send ~30 a second while - // they move) have a budget of their own, beside everything else. - let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; - self.abuse.allow(ip, what, Instant::now()) - } - - fn hosting(&mut self, file: &str) -> Option { - Some(self.host_info(Some(file))) - } - - fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { - if file.is_empty() && owner.is_empty() { - return Ok(self.host_info(None)); - } - if !self.owns(file, owner) { - return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); - } - Ok(self.host_info(Some(file))) - } - - fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); - let file = match &req { - Admin::Publish { name, snapshot } => { - return match self.publish_open(ip, name, snapshot) { - Ok((file, edit, view, owner)) => { - let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); - AdminReply::Hosted { file, edit, view, owner, expires }.into() - } - Err(e) => { - log::info!("event=publish_refused ip={ip} reason={e:?}"); - Handled::from(AdminReply::Failed { reason: e }) - } - }; - } - Admin::Owned { files } => return self.owned(files).into(), - Admin::Links { file } - | Admin::CreateLink { file, .. } - | Admin::Revoke { file, .. } - | Admin::Rename { file, .. } - | Admin::Code { file, .. } - | Admin::Remove { file } => file, - _ => return failed("that needs the server key"), - }; - if owns != Some(file.as_str()) { - return failed("an owner token manages only its own file"); - } - self.admin(req, open) - } - - fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - self.keep_report(file, link, ip, reason) - } - - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); - match req { - Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { - Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), - Err(e) => failed(e), - }, - Admin::Files => AdminReply::Files(self.list(open)).into(), - Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), - Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), - Admin::Revoke { file, link } => match self.revoke(&file, &link) { - Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, - Err(e) => failed(e), - }, - Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), - Admin::Listing => AdminReply::Listing(self.listing(open)).into(), - Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), - Admin::Block { ip } => match self.block(&ip, true) { - Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, - Err(e) => failed(e), - }, - Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), - Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), - Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), - Admin::Owned { files } => self.owned(&files).into(), - // The hub removes people itself (it knows who is connected); it never asks the store. - Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - Admin::Remove { file } => { - if !self.files.contains_key(&file) { - return failed("this file isn't on the server (it may have been removed)".into()); - } - let reply = match self.remove(&file) { - Ok(()) => AdminReply::Done, - Err(reason) => AdminReply::Failed { reason }, - }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } - } - } - } - - fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { - let failed = |reason: String| LibraryReply::Failed { reason }; - match req { - LibraryReq::Fetch { have, usage } => { - if let Some(u) = usage - && let Err(e) = self.report_usage(file, u) - { - log::warn!("file {file}: a library usage report couldn't be kept: {e}"); - } - self.fetch_library(ip, file, have).unwrap_or_else(failed) - } - LibraryReq::Publish { .. } if access != Access::Edit => { - failed("you can only view this file: publishing its library needs an edit link".into()) - } - LibraryReq::Publish { archive } => { - self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) - } - LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), - } - } - - fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { - // One address's share first (before everyone's budget is spent or anything is read). - if let PluginReq::Fetch { id, version } = &req - && let Some(size) = self.directory.size_of(id, version.as_deref()) - && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) - { - return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; - } - self.directory.handle(publisher, req) - } - - fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { - self.resolve_code(ip, code) - } - - fn closing(&self) -> Closing { - Closing::Restarting - } -} diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs new file mode 100644 index 000000000..95fd89483 --- /dev/null +++ b/apps/figcraft-server/src/store/backend.rs @@ -0,0 +1,245 @@ +use super::journal::flush_log; +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 { + let entry = self.entry(file)?; + let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) + .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) + } + + fn open(&mut self, file: &str) -> Result { + self.load(file).map_err(|e| { + let why = format!("file {file} can't be opened: {e}"); + log::error!("{why}"); + self.note(why); + "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() + }) + } + + fn changed(&mut self, file: &str, server: &mut Server) { + let journal = server.take_journal(); + if journal.is_empty() { + return; + } + let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); + let Some(e) = self.files.get_mut(file) else { return }; + let Some(log) = e.log.as_mut() else { return }; + for j in &journal { + match disk::record(j) { + Ok(r) => log.pending.extend(r), + Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), + } + } + log.entries += journal.len(); + log.dirty = true; + e.meta.updated = now(); + if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { + // The snapshot holds what's pending too. + self.compact(file, server); + } + } + + fn flush(&mut self) -> Vec { + self.flush_all() + } + + fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { + self.room_for(file, server, bytes as u64) + } + + fn idle(&mut self, file: &str, server: &mut Server) -> bool { + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) + }); + if compact { + self.compact(file, server); + } + // It closes anyway, with its state in the snapshot and durable log. + self.cut_short.retain(|f| f != file); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + // Everyone just left: idle time counts from now. + self.touch(file); + true + } + + fn stop(&mut self, open: Vec<(&str, &mut Server)>) { + self.write_usage(true); + self.flush_all(); + for (file, server) in open { + if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { + self.compact(file, server); + } + } + } + + fn manage(&mut self, key: &str) -> Result<(), String> { + match same_secret(key, &self.key) { + true => Ok(()), + false if self.cfg.hosting.open => { + Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) + } + false => Err("wrong server key".into()), + } + } + + fn connect(&mut self, ip: IpAddr) -> Result<(), String> { + if now() >= self.next_sweep { + self.sweep(); + self.write_usage(false); + } + if self.blocked.contains(&ip) { + return Err("this server doesn't accept connections from your address".into()); + } + if !self.abuse.allow(ip, Action::Connect, Instant::now()) { + return Err("too many connections from your address lately: wait a minute and try again".into()); + } + Ok(()) + } + + fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { + // Cursors and cursor chat (many people behind one address each send ~30 a second while + // they move) have a budget of their own, beside everything else. + let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; + self.abuse.allow(ip, what, Instant::now()) + } + + fn hosting(&mut self, file: &str) -> Option { + Some(self.host_info(Some(file))) + } + + fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { + if file.is_empty() && owner.is_empty() { + return Ok(self.host_info(None)); + } + if !self.owns(file, owner) { + return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); + } + Ok(self.host_info(Some(file))) + } + + fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); + let file = match &req { + Admin::Publish { name, snapshot } => { + return match self.publish_open(ip, name, snapshot) { + Ok((file, edit, view, owner)) => { + let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); + AdminReply::Hosted { file, edit, view, owner, expires }.into() + } + Err(e) => { + log::info!("event=publish_refused ip={ip} reason={e:?}"); + Handled::from(AdminReply::Failed { reason: e }) + } + }; + } + Admin::Owned { files } => return self.owned(files).into(), + Admin::Links { file } + | Admin::CreateLink { file, .. } + | Admin::Revoke { file, .. } + | Admin::Rename { file, .. } + | Admin::Code { file, .. } + | Admin::Remove { file } => file, + _ => return failed("that needs the server key"), + }; + if owns != Some(file.as_str()) { + return failed("an owner token manages only its own file"); + } + self.admin(req, open) + } + + fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + self.keep_report(file, link, ip, reason) + } + + fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + match req { + Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { + Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), + Err(e) => failed(e), + }, + Admin::Files => AdminReply::Files(self.list(open)).into(), + Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), + Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), + Admin::Revoke { file, link } => match self.revoke(&file, &link) { + Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, + Err(e) => failed(e), + }, + Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), + Admin::Listing => AdminReply::Listing(self.listing(open)).into(), + Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), + Admin::Block { ip } => match self.block(&ip, true) { + Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, + Err(e) => failed(e), + }, + Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), + Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), + Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), + Admin::Owned { files } => self.owned(&files).into(), + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + Admin::Remove { file } => { + if !self.files.contains_key(&file) { + return failed("this file isn't on the server (it may have been removed)".into()); + } + let reply = match self.remove(&file) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + }; + Handled { reply, revoked: None, removed: Some(file), blocked: None } + } + } + } + + fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { + let failed = |reason: String| LibraryReply::Failed { reason }; + match req { + LibraryReq::Fetch { have, usage } => { + if let Some(u) = usage + && let Err(e) = self.report_usage(file, u) + { + log::warn!("file {file}: a library usage report couldn't be kept: {e}"); + } + self.fetch_library(ip, file, have).unwrap_or_else(failed) + } + LibraryReq::Publish { .. } if access != Access::Edit => { + failed("you can only view this file: publishing its library needs an edit link".into()) + } + LibraryReq::Publish { archive } => { + self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) + } + LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), + } + } + + fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { + // One address's share first (before everyone's budget is spent or anything is read). + if let PluginReq::Fetch { id, version } = &req + && let Some(size) = self.directory.size_of(id, version.as_deref()) + && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) + { + return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; + } + self.directory.handle(publisher, req) + } + + fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { + self.resolve_code(ip, code) + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs new file mode 100644 index 000000000..565916ed2 --- /dev/null +++ b/apps/figcraft-server/src/store/files.rs @@ -0,0 +1,304 @@ +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. + pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { + self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) + } + + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + + /// Keep a document published without the key from `ip`, within the caps on what one address + /// and everyone may keep: the file, its links and its owner token. + pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { + let h = &self.cfg.hosting; + let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); + if !open { + return Err(KEY_ONLY.into()); + } + if !self.abuse.allow(ip, Action::Publish, Instant::now()) { + return Err("too many files published from your address lately: try again in an hour".into()); + } + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + let fits = |n: u64| { + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } + } + if used_all.saturating_add(n) > max_all { + return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); + } + Ok(()) + }; + let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; + Ok((info, edit, view, owner.unwrap_or_default())) + } + + /// Keep a document as a new file: published without the key from `ip` (it gets an owner + /// token), else by the operator. `fits` checks its size against the caps that apply. + fn keep( + &mut self, + name: &str, + snapshot: &[Change], + ip: Option, + fits: &dyn Fn(u64) -> Result<(), String>, + ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { + if self.files.len() >= self.cfg.max_files { + return Err(format!("the server keeps at most {} files", self.cfg.max_files)); + } + if let Some(why) = self.low_disk() { + return Err(why); + } + let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; + if replica.document().node_count() > self.cfg.core.max_nodes { + return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); + } + let mut doc = replica.document().clone(); + let name = match clean_name(name) { + n if n.is_empty() => doc.name.clone(), + n => n, + }; + doc.name = name.clone(); + let server = Server::new(doc, "", self.cfg.core); + let state = server.state().map_err(|e| e.to_string())?; + let bytes = disk::encode_snapshot(1, &state)?; + if bytes.len() as u64 > self.cfg.max_file_bytes { + return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); + } + fits(bytes.len() as u64)?; + let id = loop { + let id = token::file_id()?; + if !self.files.contains_key(&id) && !self.dir(&id).exists() { + break id; + } + }; + let t = now(); + let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; + let (view, view_token) = new_link(Access::View, "Published (view)", t)?; + let owner = ip.map(|_| token::secret()).transpose()?; + let meta = + Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; + let dir = self.dir(&id); + let written = fs::create_dir_all(&dir) + .map_err(|e| e.to_string()) + .and_then(|()| { + disk::sync_dir(Some(&self.root.join(FILES))); + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) + }) + .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) + .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) + // Last: a folder without its description is an upload cut short. + .and_then(|()| write_json(&dir.join(META), &meta)); + if let Err(e) = written { + let _ = fs::remove_dir_all(&dir); + return Err(format!("couldn't keep the file: {e}")); + } + let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; + self.files.insert(id.clone(), entry); + let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; + match ip { + Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), + None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), + } + Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) + } + + /// The files kept, with what the operator moderates by. + pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files + .iter() + .map(|(id, e)| FileRow { + info: self.info(id, e, open(id)), + opened: e.meta.opened, + expires: self.expires(&e.meta), + open: e.meta.owner.is_some(), + ip: e.meta.ip.clone().unwrap_or_default(), + reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), + }) + .collect() + } + + /// Owner token `owner` manages file `file` (one published without the key). + pub(super) fn owns(&self, file: &str, owner: &str) -> bool { + let presented = hash(owner); + self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) + } + + /// What became of the files these `(file id, owner token)` pairs own: each one's name, links + /// and expiry, or `None` when that token manages no file of that id here (which doesn't say + /// whether the id exists). Asking opens nothing: it doesn't count as using a file. + pub fn owned(&self, files: &[(String, String)]) -> AdminReply { + if files.len() > MAX_OWNED { + return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; + } + let one = |(file, owner): &(String, String)| { + let e = self.files.get(file).filter(|_| self.owns(file, owner))?; + Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) + }; + AdminReply::Owned(files.iter().map(one).collect()) + } + + /// Rename a file (its listing; the document inside keeps its own name). + pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { + let name = clean_name(name); + if name.is_empty() { + return Err("give the file a name".into()); + } + let path = self.dir(file).join(META); + let entry = self.entry(file)?; + let before = std::mem::replace(&mut entry.meta.name, name); + if let Err(e) = write_json(&path, &entry.meta) { + entry.meta.name = before; + return Err(format!("couldn't rename the file: {e}")); + } + Ok(()) + } + + /// Delete the files published without the key that nobody opened for the idle time (not + /// those open now); their ids. + pub fn sweep(&mut self) -> Vec { + let t = now(); + self.next_sweep = t.saturating_add(SWEEP_EVERY); + let due: Vec = + self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); + for id in &due { + match self.remove(id) { + Ok(()) => log::info!("event=expired file={id}"), + Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), + } + } + due + } + + /// Make a file look unused for `secs` more (tests: expiry without waiting). + #[cfg(test)] + pub fn age(&mut self, file: &str, secs: u64) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + let m = &mut e.meta; + (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); + let _ = write_json(&path, m); + } + } + + /// Make an open file's log refuse writes (tests: a full or failing disk). + #[cfg(test)] + 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) = std::fs::File::open(path) + { + log.file = f; + } + } + + /// Note that a file was opened now (it is used: it doesn't expire). + pub(super) fn touch(&mut self, file: &str) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + e.meta.opened = now(); + if let Err(err) = write_json(&path, &e.meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + } + + /// What the server says about its hosting (with `file`'s expiry). + pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo { + let h = &self.cfg.hosting; + let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); + HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } + } + + /// Block or unblock an address (an IPv6 address: its /64); the address as kept. + pub fn block(&mut self, ip: &str, on: bool) -> Result { + let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); + if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { + return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); + } + let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; + if changed { + let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); + write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; + log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); + } + Ok(ip) + } + + /// The blocked addresses. + pub fn blocked(&self) -> Vec { + self.blocked.iter().map(IpAddr::to_string).collect() + } + + /// The reports kept, oldest first. + pub fn reports(&self) -> &[ReportInfo] { + &self.reports + } + + /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). + pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + let reason = clean_reason(reason); + if reason.is_empty() { + return Err("say what is wrong with the file".into()); + } + let name = self.entry(file)?.meta.name.clone(); + if !self.abuse.allow(ip, Action::Report, Instant::now()) { + return Err("too many reports from your address lately: try again in an hour".into()); + } + self.reports.push(ReportInfo { + at: now(), + file: file.to_string(), + name, + link: link.unwrap_or_default().to_string(), + ip: ip.to_string(), + reason, + }); + if self.reports.len() > MAX_REPORTS { + let extra = self.reports.len() - MAX_REPORTS; + self.reports.drain(..extra); + } + write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; + log::info!("event=report file={file} ip={ip}"); + Ok(()) + } +} diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs new file mode 100644 index 000000000..5f8904fca --- /dev/null +++ b/apps/figcraft-server/src/store/journal.rs @@ -0,0 +1,273 @@ +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 + /// without the key its address's share and everyone's. A log that took it over its cap is + /// compacted first (the state may be smaller than its history). + pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { + if let Some(why) = self.low_disk() { + return Err(why); + } + let max = self.cfg.max_file_bytes; + let over = |s: &Store| s.bytes(file).saturating_add(n) > max; + // A compaction to make room at most once a minute per file (it rewrites the whole file). + let t = now(); + let may_compact = self.files.get(file).is_some_and(|e| { + t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) + }); + if over(self) && may_compact { + if let Some(e) = self.files.get_mut(file) { + e.forced = t; + } + self.compact(file, server); + } + if over(self) { + return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); + } + let h = &self.cfg.hosting; + let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; + let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + if used_all.saturating_add(n) > max_all { + return Err("this server is full: that change wasn't kept".into()); + } + Ok(()) + } + + /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). + pub(super) fn load(&mut self, file: &str) -> Result { + self.touch(file); + let dir = self.dir(file); + let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; + let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; + let mut issues = Vec::new(); + let mut fresh_log = true; + let mut kept = 0; + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { + Ok(log) if log.generation == generation => { + fresh_log = false; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); + fresh_log = true; + break; + } + kept += 1; + } + if let Some(why) = log.damaged { + issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); + fresh_log = true; + } + } + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } + Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), + } + for i in issues { + log::warn!("{i}"); + self.note(i); + } + server.journaling(true); + let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); + let log = match (fresh_log, file_handle) { + (false, Ok(f)) => { + let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); + // Replayed entries make it worth compacting once everyone has left. + Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } + } + // A new snapshot and log of the next generation, so appends start clean. + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } + }; + self.entry(file)?.log = Some(log); + Ok(server) + } + + /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the + /// document (recent batches for reconnecting clients, deletions), which may take it over. + pub(super) fn snapshot_cap(&self) -> u64 { + self.cfg.max_file_bytes.saturating_mul(2) + } + + /// Write `server`'s state as snapshot `generation` and start an empty log of it. + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + let dir = self.dir(file); + let kept = |why: String| RewriteError { why, snapshot_replaced: false }; + let state = server.state().map_err(|e| kept(e.to_string()))?; + let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; + // Never replace a readable snapshot with one that can't be opened again: the old snapshot + // and log still hold everything. + if bytes.len() as u64 > self.snapshot_cap() { + return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); + } + if bytes.len() as u64 > self.cfg.max_file_bytes { + log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); + } + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; + // From here the old log no longer goes with the snapshot on disk. + let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; + if let Ok(e) = self.entry(file) { + e.bytes = (bytes.len() + LOG_HEADER) as u64; + e.meta.seq = server.seq(); + let meta = e.meta.clone(); + if let Err(err) = write_json(&dir.join(META), &meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) + } + + /// Compactions so far. + pub fn compactions(&self) -> u64 { + self.compactions + } + + /// Why nothing more may be written when the data directory's disk is nearly full (less free + /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space + /// can't be told, nothing is refused. + pub(super) fn low_disk(&mut self) -> Option { + let now = Instant::now(); + let free = match self.free { + Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, + _ => { + let free = disk::free_bytes(&self.root); + self.free = Some((now, free)); + free + } + }; + free.filter(|f| *f < self.cfg.min_free_bytes) + .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) + } + + /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. + pub(super) fn note(&mut self, problem: String) { + if self.problems.len() >= MAX_PROBLEMS { + self.problems.remove(0); + } + self.problems.push(problem); + } + + /// Compact an open file: its state as the next snapshot, an empty log. + pub(super) fn compact(&mut self, file: &str, server: &Server) { + self.compactions += 1; + let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { + Ok(log) => { + if let Ok(e) = self.entry(file) { + e.log = Some(log); + } + log::debug!("compacted file {file}"); + } + // The new snapshot holds everything, but a restart would drop whatever is appended to + // the old log now (its generation no longer matches): the file closes, the hub hears + // it at the next flush, and it opens again from the new snapshot. + Err(RewriteError { why, snapshot_replaced: true }) => { + self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); + log::error!("file {file}: compaction was cut short: {why}"); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + self.cut_short.push(file.to_string()); + } + // The old snapshot and log still hold everything: carry on appending. + Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), + } + } + + /// Write and sync what's pending in every open log; the files whose log couldn't be written. + /// Those are closed here (their pending records dropped: nobody was told of them) and open + /// again from what is on disk. + pub(super) fn flush_all(&mut self) -> Vec { + let mut failed = std::mem::take(&mut self.cut_short); + for (id, e) in &mut self.files { + if let Some(log) = e.log.as_mut() { + match flush_log(id, log) { + Ok(n) => e.bytes += n, + Err(()) => { + e.log = None; + failed.push(id.clone()); + } + } + } + } + failed + } +} + +/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log +/// can't be trusted to hold them). +pub(super) fn flush_log(id: &str, log: &mut Log) -> Result { + if log.pending.is_empty() { + return Ok(0); + } + match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { + Ok(()) => { + let n = log.pending.len() as u64; + log.bytes += n; + log.pending.clear(); + Ok(n) + } + Err(e) => { + log::error!("file {id}: couldn't write its log: {e}"); + Err(()) + } + } +} diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs new file mode 100644 index 000000000..55ca50add --- /dev/null +++ b/apps/figcraft-server/src/store/library.rs @@ -0,0 +1,165 @@ +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 + /// everyone may download (the size is known before anything is read). + pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { + let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); + let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; + if have == Some(lib.version) { + return Ok(LibraryReply::Unchanged { version: lib.version }); + } + let path = library_file(&dir, lib.version); + let size = fs::metadata(&path).map_or(0, |m| m.len()); + let now = Instant::now(); + if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { + return Err(BUSY_DOWNLOADS.into()); + } + Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) + } + + /// Keep `archive` as a file's team library: it must read as a library (with the limits files + /// using it read it with), be the file's library if it has one, and be a newer version. + // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); + // move it to a worker if large libraries make that noticeable. + pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { + let max = self.cfg.max_file_bytes; + if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { + return Err(NO_PUBLIC_LIBRARY.into()); + } + if archive.len() as u64 > max { + return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); + } + // Everything it unpacks to counts against the same cap, so a small archive can't expand + // into a large library in memory. + let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { + Ok(Some(lib)) => lib, + Ok(None) => return Err("that isn't a published library".into()), + Err(e) => return Err(format!("the library can't be read: {e}")), + }; + if lib.doc.node_count() > self.cfg.core.max_nodes { + return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); + } + let m = &lib.manifest; + let dir = self.dir(file); + let entry = self.entry(file)?; + if let Some(old) = &entry.meta.library { + if old.id != m.id { + return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); + } + if m.version <= old.version { + return Err(format!("version {} is on the server already: publish again to follow it", old.version)); + } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); + } + // Each version has a file of its own, written before the description names it: a failed + // description write or a crash in between leaves the previous version served whole, never + // the new bytes under the old number. + let path = dir.join(library_name(m.version)); + disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; + let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); + if let Err(e) = write_json(&dir.join(META), &entry.meta) { + // Never served (the description doesn't name it); the next publish of it replaces it. + if let Err(r) = fs::remove_file(&path) { + log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); + } + entry.meta.library = before; + return Err(format!("couldn't keep the library: {e}")); + } + // The previous version isn't served any more. + if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { + log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); + } + log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); + Ok(m.version) + } + + /// A library's usage reports in memory (read from disk the first time; a damaged list starts + /// over: it is only statistics). `None` when the file has no library. + fn usage_of(&mut self, file: &str) -> Result, String> { + if self.entry(file)?.meta.library.is_none() { + return Ok(None); + } + if !self.usage.contains_key(file) { + let path = self.dir(file).join(USAGE); + let list = if path.exists() { + disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } else { + Vec::new() + }; + self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); + } + Ok(self.usage.get_mut(file)) + } + + /// The usage reports kept for a file's library (none when it has no library). + pub fn library_usage(&mut self, file: &str) -> Result, String> { + Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) + } + + /// Keep how one file uses a file's library (in memory; written when it changed, at most every + /// [`USAGE_WRITE_EVERY`] seconds). + pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { + let name = clean_name(&usage.file); + if name.is_empty() { + return Ok(()); + } + let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); + components.sort(); + components.dedup_by_key(|c| c.0); + components.truncate(MAX_REPORTED); + let Some(u) = self.usage_of(file)? else { return Ok(()) }; + match u.list.iter_mut().find(|f| f.usage.file == name) { + Some(f) if f.usage.components == components => return Ok(()), + Some(f) => { + f.usage.components = components; + f.updated = now(); + } + None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), + } + if u.list.len() > MAX_USERS { + u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); + u.list.truncate(MAX_USERS); + } + u.dirty = true; + if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { + self.write_usage(false); + } + Ok(()) + } + + /// Write the usage reports that changed (`all`: whenever they were last written). + pub(super) fn write_usage(&mut self, all: bool) { + let t = now(); + let root = self.root.join(FILES); + for (file, u) in &mut self.usage { + if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { + if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { + log::warn!("file {file}: library usage couldn't be kept: {e}"); + } + (u.dirty, u.written) = (false, t); + } + } + } +} + +/// The name of the file holding version `version` of a file's team library. +pub fn library_name(version: u64) -> String { + format!("library-{version}.bin") +} + +/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a +/// version published before each version had one. +fn library_file(dir: &Path, version: u64) -> PathBuf { + let path = dir.join(library_name(version)); + if path.exists() { path } else { dir.join(LIBRARY) } +} diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs new file mode 100644 index 000000000..6c279219f --- /dev/null +++ b/apps/figcraft-server/src/store/links.rs @@ -0,0 +1,157 @@ +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. + pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() + } + + /// A file's links. + pub fn links(&mut self, file: &str) -> Result, String> { + Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) + } + + /// A new link to a file. + pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + if entry.links.len() >= max { + return Err(format!("a file has at most {max} links: revoke one first")); + } + let (link, token) = new_link(access, &clean_name(label), now())?; + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.pop(); + return Err(format!("couldn't keep the link: {e}")); + } + Ok(NewLink { link: link.info(file), token }) + } + + /// The link of `entry` whose token `token` is (every link is compared, in constant time, so + /// timing doesn't tell which one matched). + pub(super) fn link_of(entry: &Entry, token: &str) -> Option { + let presented = hash(token); + let mut found = None; + for (i, l) in entry.links.iter().enumerate() { + if same_secret(&presented, &l.hash) { + found = Some(i); + } + } + found + } + + /// These join codes no longer open anything: typing one says so. + fn forget_codes(&mut self, codes: impl IntoIterator) { + self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); + self.gone_codes.drain(..extra); + } + + /// Where the link with the join code `key` is: its file, its place among the file's links + /// and its token (every code is compared, so timing doesn't tell which one matched). + fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { + let mut found = None; + for (id, e) in &self.files { + for (i, l) in e.links.iter().enumerate() { + if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { + found = Some((id.clone(), i, token)); + } + } + } + found + } + + /// A new join code for the link of `file` whose token is `token` (it takes the place of the + /// link's earlier code): the code, shown this once. + pub fn set_code(&mut self, file: &str, token: &str) -> Result { + let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; + // 45 random bits: one already in use is all but impossible, and never handed out twice. + let (code, key) = loop { + let code = codes::new_code()?; + let key = codes::Key::new(&code); + if self.find_code(&key).is_none() { + break (code, key); + } + }; + let sealed = key.seal(token)?; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let link = entry.links.get_mut(at).ok_or("that file has no such link")?; + let before = link.code.replace(sealed); + let id = link.id.clone(); + if let Err(e) = write_json(&path, &entry.links) { + if let Some(l) = entry.links.get_mut(at) { + l.code = before; + } + return Err(format!("couldn't keep the join code: {e}")); + } + self.forget_codes(before); + log::info!("event=code file={file} link={id}"); + Ok(code) + } + + /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against + /// the address (see `abuse`), and nothing is looked up for an address that is locked out. + pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { + let now = Instant::now(); + if let Some(left) = self.abuse.code_locked(ip, now) { + return Err(format!( + "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", + left.as_secs() / 60 + 1 + )); + } + if !self.abuse.allow(ip, Action::Code, now) { + return Err("Too many codes tried from your address: wait a minute and try again".into()); + } + if !self.abuse.codes_open(now) { + return Err(CODES_BUSY.into()); + } + // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). + let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); + let found = key.as_ref().and_then(|k| self.find_code(k)); + self.abuse.code_tried(ip, found.is_some(), now); + match found { + Some((file, _, token)) => { + log::info!("event=code_used file={file} ip={ip}"); + Ok((file, token)) + } + None => { + let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); + Err(if gone { CODE_GONE } else { NO_CODE }.into()) + } + } + } + + /// Revoke a link. + pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; + let gone = entry.links.remove(at); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.insert(at, gone); + return Err(format!("couldn't revoke the link: {e}")); + } + log::info!("revoked link {link} of file {file}"); + self.forget_codes(gone.code); + Ok(()) + } + + /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no + /// longer served either way). + pub fn remove(&mut self, file: &str) -> Result<(), String> { + // Closed first: an open log would keep the folder from being deleted on Windows. + let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; + self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + drop(entry); + let dir = self.dir(file); + fs::remove_dir_all(&dir) + .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; + log::info!("removed file {file}"); + Ok(()) + } +} diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bd8c09c81..2b5a3bd14 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block # unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "macos")'.dependencies] fmv-macos-events = "=0.1.0" +# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the +# same guarded path as the close button); winit's default Quit ends the process through +# `terminate:`, losing unsaved changes without a word (issue #857). +craft-menu = { workspace = true } [dev-dependencies] wat = { workspace = true } diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index a3cd21bf5..5371045d0 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -271,6 +271,15 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx)); + // winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the + // app, so unsaved changes are lost with no warning (issue #857). This menu's Quit + // sends the root window a close request instead, and the unsaved-changes question + // asks just as it does for the close button. Failure keeps winit's menu: the app + // runs, but ⌘Q ends it without asking. + #[cfg(target_os = "macos")] + if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") { + log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}"); + } Ok(Box::new(App { queue, ctx_slot, diff --git a/clippy.toml b/clippy.toml index 724e05c5f..305f94bdd 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/driver.rs b/crates/collab-net/src/driver.rs index 4ebbec157..84801c4d5 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -2,9 +2,10 @@ //! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's //! threads do — hello on open (resuming as the same client after a drop), presence held until the //! welcome and changes made for an earlier connection dropped (the sync client sends again what -//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as -//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer -//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`]. +//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every +//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with +//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the +//! WebSocket's events and wakes it at [`Driver::deadline`]. //! //! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a //! connection of its own, then joins that file as with a link; a server that doesn't know the @@ -36,13 +37,15 @@ struct Conn { opened: bool, welcomed: bool, seen: Seen, - last_sent: Instant, + /// The last ping (or the connection's start): one goes out every keep-alive interval, whatever + /// else is sent, since presence gets no answer and only what the host sends counts as heard. + last_ping: Instant, last_heard: Instant, } impl Conn { fn new(now: Instant) -> Self { - Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now } + Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now } } } @@ -106,7 +109,7 @@ impl Driver { Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }), None => hello(&self.cfg, &self.resume), }; - if !self.write(&hello, now) { + if !self.write(&hello) { self.end(Outcome::Retry("the connection dropped".into()), now); } } @@ -173,7 +176,7 @@ impl Driver { return; } if let Some(p) = self.throttle.offer(p, now) - && !self.write(&ClientMsg::Presence(p), now) + && !self.write(&ClientMsg::Presence(p)) { self.end(Outcome::Retry("the connection dropped".into()), now); } @@ -186,7 +189,7 @@ impl Driver { if !self.conn.as_ref().is_some_and(|c| c.welcomed) { return; } - if !self.write(&msg, now) { + if !self.write(&msg) { self.end(Outcome::Retry("the connection dropped".into()), now); } } @@ -204,7 +207,7 @@ impl Driver { let l = &self.cfg.limits; let conn = self.conn.as_ref().map(|c| { if c.opened { - [Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }] + [Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }] } else { [Some(c.started + self.cfg.connect_timeout), None, None] } @@ -223,7 +226,7 @@ impl Driver { } let l = self.cfg.limits; let Some(c) = self.conn.as_ref() else { return }; - let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent); + let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping); if !opened { if now >= started + self.cfg.connect_timeout { self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now); @@ -233,21 +236,23 @@ impl Driver { if now >= last_heard + l.idle_timeout { return self.end(Outcome::Retry("the host stopped answering".into()), now); } - let ok = match self.throttle.take_due(now) { - Some(p) => self.write(&ClientMsg::Presence(p), now), - None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now), - None => true, - }; + let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p))); + let ok = ok && (now < last_ping + l.keepalive || self.ping(now)); if !ok { self.end(Outcome::Retry("the connection dropped".into()), now); } } - /// Send one message; false when it couldn't go. - fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool { + /// Send a ping; false when it couldn't go. + fn ping(&mut self, now: Instant) -> bool { if let Some(c) = self.conn.as_mut() { - c.last_sent = now; + c.last_ping = now; } + self.write(&ClientMsg::Ping) + } + + /// Send one message; false when it couldn't go. + fn write(&mut self, msg: &ClientMsg) -> bool { message::encode(msg).is_ok_and(|b| self.sock.send(&b)) } diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index 5e395b981..a94ff0645 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() { d.tick(due); assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0)))); let n = d.sock.sent.len(); - // Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic). + // A ping every keep-alive interval from the connection's start, presence or not (the host + // answers, which counts as traffic). let ping_at = d.deadline().unwrap(); - assert_eq!(ping_at, due + Duration::from_secs(15)); + assert_eq!(ping_at, t0 + Duration::from_secs(15)); d.tick(ping_at - ms(1)); assert_eq!(d.sock.sent.len(), n, "not yet"); d.message(&encode(&ServerMsg::Pong), ping_at - ms(1)); @@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() { assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping)); } +/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the +/// pings still go out, and the host's pongs keep the connection (before, each presence put the +/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection). +#[test] +fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() { + let t0 = Instant::now(); + let (mut d, inbox) = start(Script::default(), t0); + d.opened(t0); + d.message(&welcome(), t0); + inbox.drain(); + let mut answered = 0; + for i in 1..=900u32 { + let now = t0 + ms(100 * u64::from(i)); + d.presence(cursor(f64::from(i)), now); + while let Some(due) = d.deadline().filter(|t| *t <= now) { + d.tick(due); + } + // The host answers every ping (and nothing else) at once. + let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping); + answered = d.sock.sent.len(); + if pinged { + d.message(&encode(&ServerMsg::Pong), now); + } + } + assert!(!d.done()); + assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected"); + assert!(statuses(&inbox.drain()).is_empty()); + let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count(); + assert!(pings >= 5, "a ping every 15 s ({pings})"); +} + #[test] fn a_silent_host_counts_as_dropped_and_the_client_reconnects() { let t0 = Instant::now(); diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 2e1d5046c..91e72cf67 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -1,7 +1,8 @@ //! Joining a session: the client end of the transport, with reconnect. //! //! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a -//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends. +//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the +//! host sends. //! When the connection drops after having been live, it reconnects with growing pauses; a //! refusal or the host ending the session is final. //! @@ -280,13 +281,15 @@ fn session( } } }; - let mut last_sent = Instant::now(); + // A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the + // reader counts only what the host sends. + let mut last_ping = Instant::now(); let hello = hello(cfg, resume); let mut welcomed = false; let mut throttle = Throttle::new(PRESENCE_INTERVAL); - let outcome = if send(&mut writer, &hello, &mut last_sent) { + let outcome = if send(&mut writer, &hello) { loop { - let ping_at = last_sent + cfg.limits.keepalive; + let ping_at = last_ping + cfg.limits.keepalive; let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at); let mut out = None; let mut now_send = Vec::new(); @@ -312,16 +315,17 @@ fn session( Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {} Err(RecvTimeoutError::Timeout) => {} } - if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) { + if !now_send.iter().all(|m| send(&mut writer, m)) { break Outcome::Retry("the connection dropped".into()); } let now = Instant::now(); let out = out.or_else(|| throttle.take_due(now)); - let ok = match out { - Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent), - None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent), - None => true, - }; + let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p))); + let ok = ok + && (now < last_ping + cfg.limits.keepalive || { + last_ping = now; + send(&mut writer, &ClientMsg::Ping) + }); if !ok { break Outcome::Retry("the connection dropped".into()); } @@ -335,8 +339,7 @@ fn session( } /// Send one message; false when the connection is gone. -fn send(ws: &mut WebSocket, msg: &ClientMsg, last_sent: &mut Instant) -> bool { - *last_sent = Instant::now(); +fn send(ws: &mut WebSocket, msg: &ClientMsg) -> bool { message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok()) } diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 1471c1798..363ecae08 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -46,1047 +46,27 @@ use crate::join::{ENDED, RESTARTING}; use crate::net::{Bucket, Duplex, Meter, NetLimits, Readers, config, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; -/// How long a closing connection waits for the peer to hang up. -const LINGER: Duration = Duration::from_secs(2); +mod backend; +mod conn; +mod hosting; +mod limits; +mod queue; -/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; -/// beyond that they are dropped. -pub const SIGNAL_RATE: f64 = 20.0; -pub const SIGNAL_BURST: f64 = 40.0; +pub use backend::{Backend, Closing, Handled, OpenFile}; +pub use conn::{SIGNAL_BURST, SIGNAL_RATE}; +use conn::{encode, reason, refuse, refused}; +pub use hosting::Hosting; +pub(crate) use hosting::{Local, clean_name}; +pub use limits::{Admission, Block, ip_blocks, ip_key}; +pub use queue::wide_block; +#[cfg(test)] +use queue::{BlockUse, Blocks}; +use queue::{Charges, HubIn, InFlight, Line, Out, Push}; /// Most messages the hub handles before it sends what they produced (bounds the delay a burst /// adds). const BURST: usize = 256; -/// What a link lets a hello do. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Admission { - pub access: Access, - /// The link's id, when the backend has several per file (revoking it disconnects whoever - /// joined with it). - pub link: Option, -} - -/// An admin request's reply, and what the hub must do about connections it affects. -#[derive(Clone, Debug, PartialEq)] -pub struct Handled { - pub reply: AdminReply, - /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. - pub revoked: Option<(String, String)>, - /// A file was removed: whoever is in it is disconnected and it is closed without saving. - pub removed: Option, - /// An address was blocked ([`ip_key`]): its connections are dropped. - pub blocked: Option, -} - -impl From for Handled { - fn from(reply: AdminReply) -> Self { - Self { reply, revoked: None, removed: None, blocked: None } - } -} - -/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address -/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). -pub fn ip_key(ip: IpAddr) -> IpAddr { - match ip.to_canonical() { - IpAddr::V6(v6) => { - let [a, b, c, d, ..] = v6.segments(); - IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) - } - v4 => v4, - } -} - -/// What everyone is told when a hub stops. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Closing { - /// The session is over (the app stopped hosting): final. - Ended, - /// The server is restarting: clients reconnect until it is back. - Restarting, -} - -/// What the hub knows of an open file (for listings). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct OpenFile { - /// People connected to it. - pub people: u32, - /// Its latest sequence number. - pub seq: u64, -} - -/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it -/// from its own thread only. -pub trait Backend: Send + 'static { - /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is - /// shown to the person joining; never echo the token). - fn admit(&mut self, file: &str, token: &str) -> Result; - - /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). - fn open(&mut self, file: &str) -> Result; - - /// The hub handled a message for an open file: keep what changed (its server's journal). - fn changed(&mut self, _file: &str, _server: &mut Server) {} - - /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers - /// after this. The files whose changes couldn't be made durable: nobody hears of them, the - /// files are closed (the backend forgets their open state) and everyone in them reconnects. - fn flush(&mut self) -> Vec { - Vec::new() - } - - /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and - /// the batch is refused. - fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { - Ok(()) - } - - /// Everyone left `file`: save it. True closes it (the next hello opens it again). - fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { - false - } - - /// The hub is stopping: save the open files. - fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} - - /// A managing connection's key: whether it may manage files. - fn manage(&mut self, _key: &str) -> Result<(), String> { - Err("this session is hosted by an app: it has no files to manage".into()) - } - - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// A request on a library connection to `file`, opened with a link of `access` (checked by - /// [`admit`](Self::admit)). - fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { - LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } - } - - /// A request on a plugin directory connection; `publisher`: it was opened with the right key - /// (checked by [`manage`](Self::manage)). - fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { - PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } - } - - /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the - /// client). - fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { - Ok(()) - } - - /// A message from a connection from `ip`: false disconnects it (that address sends too much). - fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { - true - } - - /// What to tell a client that reads it about the server's hosting, after its welcome to - /// `file` (`None`: nothing). - fn hosting(&mut self, _file: &str) -> Option { - None - } - - /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: - /// what the server says about its hosting, or why the connection is refused. - fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { - Err("this session is hosted by an app: it doesn't keep files for anyone".into()) - } - - /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it - /// was opened with. - fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. - fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { - Err("this session is hosted by an app: tell the person hosting it".into()) - } - - /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not - /// (shown to them; wrong codes are counted against the address). - fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { - Err("this session is hosted by an app: join it with its own code or invite link".into()) - } - - /// What everyone is told when the hub stops. - fn closing(&self) -> Closing; -} - -/// A message for one connection's writer: bytes (with what they count against, released when -/// they are sent or dropped), or the close. -enum Out { - Bytes(Bytes, Option), - Close(CloseCode, String), -} - -/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted -/// once against a shared counter (the server's, or a block's) until the last copy is sent or -/// dropped. -struct Held { - n: usize, - counter: Arc, -} - -impl Held { - fn new(n: usize, counter: &Arc) -> Arc { - counter.fetch_add(n, Ordering::Relaxed); - Arc::new(Held { n, counter: counter.clone() }) - } -} - -impl Drop for Held { - fn drop(&mut self) { - self.counter.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// What one message counts against, made by its first recipient and shared by the others: the -/// server's budget once, and each recipient block's once (twenty people behind one office's -/// address cost their block one copy of a broadcast, not twenty). -#[derive(Default)] -struct Charges { - all: Option>, - blocks: Vec<(usize, Arc)>, - /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each - /// connection's cap, and a whole office joining at once must fit). - skip_blocks: bool, -} - -/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through -/// [`Held`], once against the server's budget and once against its block's). -struct Charge { - n: usize, - mine: Arc, - _all: Arc, - _block: Option>, -} - -impl Drop for Charge { - fn drop(&mut self) { - self.mine.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// Why a message couldn't be queued. -#[derive(Debug, PartialEq, Eq)] -enum Push { - /// The connection's or its block's queue is full: it can't keep up. - Full, - /// The server's budget for queued bytes is used up. - Everyone, - /// The connection is gone. - Gone, -} - -/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its -/// block of addresses', and the server's for everyone), so a client that doesn't read can't make -/// the server hold more than that for it. -#[derive(Clone)] -struct Line { - tx: SyncSender, - queued: Arc, - block: Arc, - all: Arc, - max: usize, - max_block: usize, - max_all: usize, -} - -impl Line { - /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file - /// gets out). `charges` is what the message counts against, shared by its recipients; - /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer - /// to make room). - fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { - let n = b.len(); - let mine = self.queued.load(Ordering::Relaxed); - if mine > 0 && mine.saturating_add(n) > self.max { - return Err(Push::Full); - } - let key = Arc::as_ptr(&self.block) as usize; - let block = if charges.skip_blocks { - None - } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { - Some(h.clone()) - } else { - let used = self.block.load(Ordering::Relaxed); - if used > 0 && used.saturating_add(n) > self.max_block { - return Err(Push::Full); - } - let h = Held::new(n, &self.block); - charges.blocks.push((key, h.clone())); - Some(h) - }; - let all = match &charges.all { - Some(h) => h.clone(), - None => { - if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { - return Err(Push::Everyone); - } - charges.all.insert(Held::new(n, &self.all)).clone() - } - }; - self.queued.fetch_add(n, Ordering::Relaxed); - let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; - self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { - TrySendError::Full(_) => Push::Full, - TrySendError::Disconnected(_) => Push::Gone, - }) - } -} - -/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is -/// having read, shared by its connections. -#[derive(Clone, Default)] -struct BlockUse { - queued: Arc, - reading: Arc, -} - -/// The blocks with open connections; a block leaves when its last connection ends. -#[derive(Clone, Default)] -struct Blocks(Arc>>); - -impl Blocks { - /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, - /// whose connections' queues and readers are all gone) are forgotten first, so the table - /// never holds more than the blocks of open connections. - fn get(&self, key: IpAddr) -> BlockUse { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); - t.entry(key).or_default().clone() - } - - /// Blocks remembered. - fn len(&self) -> usize { - self.0.lock().unwrap_or_else(PoisonError::into_inner).len() - } -} - -/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an -/// IPv6 address's /48. -pub fn wide_block(key: IpAddr) -> IpAddr { - ip_blocks(key).last().map_or(key, |(b, _)| b.0) -} - -/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader -/// drops its connection instead of piling more on a busy hub. -#[derive(Clone)] -struct InFlight(Arc); - -impl InFlight { - fn take(&self, n: usize, max: usize) -> bool { - if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { - self.0.fetch_sub(n, Ordering::Relaxed); - return false; - } - true - } - - fn give(&self, n: usize) { - self.0.fetch_sub(n, Ordering::Relaxed); - } -} - -enum HubIn { - /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, - /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). - Open { - conn: u64, - ip: IpAddr, - first: ClientMsg, - out: Line, - sock: TcpStream, - /// Its size (in flight until handled). - bytes: usize, - /// Told when the hub takes the connection in (dropped unsent when it doesn't). - admitted: SyncSender<()>, - }, - Msg { - conn: u64, - msg: ClientMsg, - bytes: usize, - }, - Gone { - conn: u64, - }, - Local(Presence), - LocalMsg(ClientMsg), - /// The person hosting removes client `id` from their file (app sessions). - Remove(u32), - /// Cut every connection without a word (tests: a network failure). - #[cfg(test)] - Cut, - Stop, -} - -/// The person hosting an app session: in the file's session without a socket. -pub(crate) struct Local { - pub file: String, - pub server: Server, - /// Their client id (they joined `server` already). - pub id: u32, - pub inbox: Inbox, -} - -fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { - std::thread::Builder::new().name(name.to_string()).spawn(f) -} - -/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. -pub(crate) fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() -} - -/// A running hub: its listener, connections and hub thread. Dropping it stops it. -pub struct Hosting { - addr: SocketAddr, - hub: Sender, - stop: Arc, - open: Arc, - /// Bytes queued for every connection together, and in flight to the hub. - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - reading: Arc, - hub_thread: Option>, - accept_thread: Option>, -} - -impl Hosting { - /// Serve `backend`'s files on `listener`. - pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { - Self::start(listener, backend, limits, None) - } - - pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { - let addr = listener.local_addr()?; - let in_flight = InFlight(Arc::default()); - let mut hub = Hub { - backend, - rooms: HashMap::new(), - conns: HashMap::new(), - local: None, - outbox: Vec::new(), - in_flight: in_flight.clone(), - barred: HashMap::new(), - }; - if let Some(l) = local { - let mut room = Room::new(l.server); - room.local = Some((l.id, l.inbox)); - hub.rooms.insert(l.file.clone(), room); - hub.local = Some(l.file); - } - let (hub_tx, hub_rx) = channel(); - let stop = Arc::new(AtomicBool::new(false)); - let open = Arc::new(AtomicUsize::new(0)); - let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; - let shared = Shared { - per_address: PerAddress::default(), - queued: Arc::default(), - in_flight: in_flight.clone(), - blocks: Blocks::default(), - reading: Arc::default(), - readers: Readers::default(), - }; - let queued = shared.queued.clone(); - let blocks = shared.blocks.clone(); - let reading = shared.reading.clone(); - let accept_thread = { - let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); - spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) - }; - let accept_thread = match accept_thread { - Ok(t) => t, - Err(e) => { - let _ = hub_tx.send(HubIn::Stop); - let _ = hub_thread.join(); - return Err(e); - } - }; - log::info!("live session: listening on {addr}"); - Ok(Hosting { - addr, - hub: hub_tx, - stop, - open, - queued, - in_flight, - blocks, - reading, - hub_thread: Some(hub_thread), - accept_thread: Some(accept_thread), - }) - } - - /// Bytes waiting to be sent to clients, all connections together. - pub fn queued_bytes(&self) -> usize { - self.queued.load(Ordering::Relaxed) - } - - /// Blocks of addresses the server keeps counters for (those of open connections, and any - /// whose last connection just ended, until the next connection prunes them). - pub fn blocks_remembered(&self) -> usize { - self.blocks.len() - } - - /// Bytes being read into messages not whole yet, all connections together. - pub fn reading_bytes(&self) -> usize { - self.reading.load(Ordering::Relaxed) - } - - /// Bytes of messages read and not handled by the hub yet. - pub fn in_flight_bytes(&self) -> usize { - self.in_flight.0.load(Ordering::Relaxed) - } - - /// Where it listens. - pub fn addr(&self) -> SocketAddr { - self.addr - } - - /// Open connections (joined or not). - pub fn connections(&self) -> usize { - self.open.load(Ordering::Relaxed) - } - - /// The hosting person's presence (app sessions). - pub(crate) fn presence(&self, p: Presence) { - let _ = self.hub.send(HubIn::Local(p)); - } - - /// A message from the hosting person's sync client (their edits). - pub(crate) fn send(&self, msg: ClientMsg) { - let _ = self.hub.send(HubIn::LocalMsg(msg)); - } - - /// The hosting person removes client `id` from their file (app sessions; see - /// [`HubIn::Remove`]). - pub(crate) fn remove(&self, id: u32) { - let _ = self.hub.send(HubIn::Remove(id)); - } - - /// Cut every connection as a network failure would (no close frame). - #[cfg(test)] - pub(crate) fn cut_connections(&self) { - let _ = self.hub.send(HubIn::Cut); - } - - /// Stop: the backend saves what's open, everyone is told and disconnected, the port is - /// released. - pub fn stop(&mut self) { - if self.stop.swap(true, Ordering::SeqCst) { - return; - } - let _ = self.hub.send(HubIn::Stop); - if let Some(t) = self.hub_thread.take() { - let _ = t.join(); - } - // The accept loop checks the flag after each connection: wake it with one. - let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; - let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); - if let Some(t) = self.accept_thread.take() { - if woke { - let _ = t.join(); - } else { - log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); - } - } - // Readers end once their writers have closed the sockets. - let deadline = Instant::now() + Duration::from_secs(2); - while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - log::info!("live session: stopped listening on {}", self.addr); - } -} - -impl Drop for Hosting { - fn drop(&mut self) { - self.stop(); - } -} - -/// The loopback address of `addr`'s family. -fn loopback(addr: SocketAddr) -> std::net::IpAddr { - if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } -} - -/// Counts an open connection until dropped. -struct Open(Arc); - -impl Drop for Open { - fn drop(&mut self) { - self.0.fetch_sub(1, Ordering::Relaxed); - } -} - -/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a -/// /48 are different blocks). -pub type Block = (IpAddr, u8); - -/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each -/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 -/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. -pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { - let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; - let [a, b, c, d, ..] = v6.segments(); - vec![ - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), - ] -} - -/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address -/// leaves when its last one closes, so the table never holds more than the open connections. -#[derive(Clone, Default)] -struct PerAddress(Arc>>); - -/// One connection counted for a block until dropped. -struct Counted { - table: PerAddress, - ip: Block, -} - -impl PerAddress { - /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). - fn enter(&self, ip: Block, cap: usize) -> Option { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - let n = t.entry(ip).or_insert(0); - if cap > 0 && *n >= cap { - if *n == 0 { - t.remove(&ip); - } - return None; - } - *n += 1; - Some(Counted { table: self.clone(), ip }) - } - - /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider - /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of - /// `all` connections, so no block takes every connection. - fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { - let mut held = Vec::new(); - for ((addr, prefix), times) in ip_blocks(ip) { - let block_cap = match prefix { - 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), - 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), - _ => cap, - }; - held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); - } - Some(held) - } -} - -impl Drop for Counted { - fn drop(&mut self) { - let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(n) = t.get_mut(&self.ip) { - *n = n.saturating_sub(1); - if *n == 0 { - t.remove(&self.ip); - } - } - } -} - -/// Longest request line an uptime check may have. -const MAX_CHECK_LINE: usize = 2048; - -/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. -/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket -/// handshake goes on untouched. -fn health_check(stream: &mut TcpStream, until: Instant) -> bool { - let mut buf = [0u8; MAX_CHECK_LINE]; - let line = loop { - let n = match stream.peek(&mut buf) { - Ok(0) | Err(_) => return false, - Ok(n) => n, - }; - let seen = buf.get(..n).unwrap_or_default(); - if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { - return false; - } - if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { - break seen.get(..end).unwrap_or_default().to_vec(); - } - if n == buf.len() || Instant::now() >= until { - return false; - } - // The rest of the line is on its way (it nearly always arrives in one piece). - std::thread::sleep(Duration::from_millis(10)); - }; - let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); - let path = path.split(|b| *b == b'?').next().unwrap_or_default(); - if !path.ends_with(b"/healthz") { - return false; - } - // Read the request (its headers end with a blank line), so closing doesn't reset it. - let mut req = Vec::new(); - let mut chunk = [0u8; 1024]; - while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { - match stream.read(&mut chunk) { - Ok(0) | Err(_) => break, - Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), - } - } - let _ = stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); - let _ = stream.shutdown(std::net::Shutdown::Write); - true -} - -/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the -/// proxy added; any before it are whatever the client sent). -fn forwarded_for(req: &Request) -> Option { - let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; - let entry = last.rsplit(',').next()?.trim(); - entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) -} - -/// What every connection shares: the count per address, the bytes queued for all of them and the -/// bytes in flight to the hub. -#[derive(Clone)] -struct Shared { - per_address: PerAddress, - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - /// Bytes being read into messages, all connections together, and who is reading them. - reading: Arc, - readers: Readers, -} - -fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { - let mut next = 0u64; - for stream in listener.incoming() { - if stop.load(Ordering::SeqCst) { - break; - } - let stream = match stream { - Ok(s) => s, - Err(e) => { - // Out of sockets or similar: don't spin. - log::warn!("live session: accept failed: {e}"); - std::thread::sleep(Duration::from_millis(50)); - continue; - } - }; - if open.load(Ordering::Relaxed) >= limits.max_connections { - log::info!("live session: too many connections; refusing one"); - continue; - } - open.fetch_add(1, Ordering::Relaxed); - let guard = Open(open.clone()); - next += 1; - let (conn, hub, shared) = (next, hub.clone(), shared.clone()); - if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { - log::warn!("live session: can't start a connection thread: {e}"); - } - } -} - -/// A close reason that fits a WebSocket close frame (123 bytes). -fn reason(s: &str) -> String { - let mut end = s.len().min(123); - while !s.is_char_boundary(end) { - end -= 1; - } - s.get(..end).unwrap_or_default().to_string() -} - -fn encode(msg: &ServerMsg) -> Option { - match message::encode(msg) { - Ok(b) => Some(Bytes::from(b)), - Err(e) => { - log::warn!("live session: can't encode a message: {e}"); - None - } - } -} - -/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). -fn refuse(out: &Line, why: &str) { - refused(out, why); -} - -/// [`refuse`], and whether the close was queued (it isn't when the queue is full). -fn refused(out: &Line, why: &str) -> bool { - if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { - let _ = out.tx.try_send(Out::Bytes(b, None)); - } - out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() -} - -/// What the server tells a client when it is too busy to take more from it. -const BUSY: &str = "the server is busy: try again in a moment"; - -/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { - loop { - return match ws.read() { - Ok(Message::Binary(b)) => { - // Whole now: it counts as in flight from here, no longer as being read. - ws.get_mut().read_done(); - message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) - } - Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), - // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and - // a message trickled in is cut off by it (`Duplex::read_until`). - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) - if deadline.is_some_and(|d| Instant::now() >= d) => - { - Err("no hello in time".into()) - } - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, - Ok(Message::Close(_)) => Ok(None), - Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), - // The reading budget's refusal (see `Meter`). - Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), - Err(_) => Ok(None), - }; - } -} - -/// Why a message over `max` bytes is refused. -fn too_large(max: usize) -> String { - match max >> 20 { - 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), - mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), - } -} - -/// One connection's reader (the writer runs on its own thread). -fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { - let _ = stream.set_nodelay(true); - let hello_deadline = Instant::now() + limits.hello_timeout; - if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { - return; - } - if health_check(&mut stream, Instant::now() + limits.hello_timeout) { - return; - } - let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; - let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; - // The handshake and the hello must be in by the deadline, however slowly they trickle in. - rd.read_until(Some(hello_deadline)); - let cfg = config(limits.max_client_message); - let mut forwarded = None; - // The handshake callback's type is tungstenite's (its error is an HTTP response). - #[allow(clippy::result_large_err)] - let read_client = |req: &Request, resp: Response| -> Result { - if proxied { - forwarded = forwarded_for(req); - } - Ok(resp) - }; - let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { - Ok(ws) => ws, - Err(e) => { - log::debug!("live session: handshake failed: {e}"); - shutdown(&sock); - return; - } - }; - if proxied && forwarded.is_none() { - log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); - } - let ip = ip_key(forwarded.unwrap_or(peer)); - let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); - let wide = wide_block(ip); - let block = shared.blocks.get(wide); - ws.get_mut().meter(Meter::new( - conn, - &sock, - &shared.readers, - block.reading.clone(), - shared.reading.clone(), - limits.max_reading_block, - limits.max_reading_total, - )); - let (tx, rx) = sync_channel::(limits.queue.max(4)); - let out = Line { - tx, - queued: Arc::default(), - block: block.queued.clone(), - all: shared.queued.clone(), - max: limits.max_queued_bytes, - max_block: limits.max_queued_block, - max_all: limits.max_queued_total, - }; - let writer = - match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; - let end = |why: Option<&str>| { - if let Some(why) = why { - refuse(&out, why); - } else { - let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); - } - }; - if counted.is_none() { - log::info!("event=refused ip={ip} reason=connections_per_address"); - end(Some("too many connections from your address: close some and try again")); - } else { - let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); - // In time or not, the hello's deadline is over: what's read next has timeouts of its own. - ws.get_mut().read_until(None); - match hello { - Ok(Some(( - first @ (ClientMsg::Hello(_) - | ClientMsg::Manage(_) - | ClientMsg::OpenLibrary(_) - | ClientMsg::OpenPlugins(_) - | ClientMsg::OpenHosting(_) - | ClientMsg::Resolve(_)), - bytes, - ))) => { - // Sessions and the operator may use the last connections; one-shot requests - // (hosting, libraries, browsing plugins) leave them free. - let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); - // A publish uploads a whole file; a large one on a slow link takes the server a - // while to receive and answer. The manage/hosting connections that carry uploads - // wait the longer request-reply timeout, not the short request idle. - let idle = match first { - ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, - ClientMsg::Hello(_) => limits.idle_timeout, - _ => limits.request_idle, - }; - let max = match &first { - ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, - _ => limits.max_client_message, - }; - let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); - // (In flight is only taken when the connection goes on: the hub gives it back.) - if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { - end(Some(BUSY)); - } else { - let (admitted, verdict) = sync_channel(1); - let opened = - sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); - // Nothing more is read until the hub took the connection in (its token checked): - // only then may it send messages of the full size. - if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { - ws.set_config(|c| *c = config(max)); - read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); - let _ = hub.send(HubIn::Gone { conn }); - } - } - } - Ok(Some(_)) => end(Some("say hello first")), - Ok(None) => {} - Err(why) => end(Some(&why)), - } - } - drop(counted); - // The block's counters go with the last handle on them (the table forgets it then). - drop(block); - ws.get_mut().read_until(None); - linger(&mut ws); - drop(out); - // The writer closes the socket once it has sent what's queued (a refusal, a close). - let _ = writer.join(); - shutdown(&sock); -} - -fn read_loop( - ws: &mut WebSocket, - conn: u64, - hub: &Sender, - limits: NetLimits, - max: usize, - in_flight: &InFlight, - end: &dyn Fn(Option<&str>), -) { - let mut bucket = Bucket::new(limits.rate, limits.burst); - let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); - loop { - match next_message(ws, max, None) { - Ok(Some((msg, bytes))) => { - let now = Instant::now(); - if !bucket.take(now) { - log::info!("live session: connection {conn} sends too much; disconnecting it"); - return end(Some("too many messages")); - } - // Typing sends a chat signal per keystroke; more than this is dropped. - if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { - log::debug!("live session: connection {conn} sends signals too fast; dropping one"); - continue; - } - if !in_flight.take(bytes, limits.max_in_flight) { - log::info!("live session: the hub is behind; dropping connection {conn}"); - return end(Some(BUSY)); - } - if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { - return; - } - } - Ok(None) => return end(None), - Err(why) => return end(Some(&why)), - } - } -} - -fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { - let mut close = Some((CloseCode::Normal, String::new())); - while let Ok(o) = rx.recv() { - match o { - Out::Bytes(b, _charge) => { - if ws.send(Message::Binary(b)).is_err() { - close = None; - break; - } - } - Out::Close(code, why) => { - close = Some((code, why)); - break; - } - } - } - if let Some((code, why)) = close { - // Close politely: the close frame, then no more writing; the reader drains what the peer - // still sends until it hangs up (closing on unread data would reset the connection and - // could lose the close frame), or until the linger runs out. - let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); - let _ = sock.shutdown(std::net::Shutdown::Write); - let until = Instant::now() + LINGER; - while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { - if Instant::now() >= until { - break; - } - } - } - shutdown(sock); -} - -/// Read and drop what the peer still sends after the close, until it hangs up (at most -/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader -/// is mid-frame and mustn't be asked for more. -fn linger(ws: &mut WebSocket) { - let until = Instant::now() + LINGER; - let mut buf = [0u8; 4096]; - while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { - match ws.get_mut().read(&mut buf) { - Ok(0) => break, - Ok(_) => {} - Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} - Err(_) => break, - } - } -} - /// What a connection is for. enum Kind { /// In `file`'s session as client `id`, through link `link`. @@ -1783,13 +763,10 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { } #[cfg(test)] -#[path = "queue_tests.rs"] mod queue_tests; #[cfg(test)] -#[path = "barred_tests.rs"] mod barred_tests; #[cfg(test)] -#[path = "preauth_tests.rs"] mod preauth_tests; diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs new file mode 100644 index 000000000..e594b0f1b --- /dev/null +++ b/crates/collab-net/src/hub/backend.rs @@ -0,0 +1,138 @@ +use super::limits::Admission; +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)] +pub struct Handled { + pub reply: AdminReply, + /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. + pub revoked: Option<(String, String)>, + /// A file was removed: whoever is in it is disconnected and it is closed without saving. + pub removed: Option, + /// An address was blocked ([`ip_key`]): its connections are dropped. + pub blocked: Option, +} + +impl From for Handled { + fn from(reply: AdminReply) -> Self { + Self { reply, revoked: None, removed: None, blocked: None } + } +} + +/// What everyone is told when a hub stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Closing { + /// The session is over (the app stopped hosting): final. + Ended, + /// The server is restarting: clients reconnect until it is back. + Restarting, +} + +/// What the hub knows of an open file (for listings). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OpenFile { + /// People connected to it. + pub people: u32, + /// Its latest sequence number. + pub seq: u64, +} + +/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it +/// from its own thread only. +pub trait Backend: Send + 'static { + /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is + /// shown to the person joining; never echo the token). + fn admit(&mut self, file: &str, token: &str) -> Result; + + /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). + fn open(&mut self, file: &str) -> Result; + + /// The hub handled a message for an open file: keep what changed (its server's journal). + fn changed(&mut self, _file: &str, _server: &mut Server) {} + + /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers + /// after this. The files whose changes couldn't be made durable: nobody hears of them, the + /// files are closed (the backend forgets their open state) and everyone in them reconnects. + fn flush(&mut self) -> Vec { + Vec::new() + } + + /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and + /// the batch is refused. + fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { + Ok(()) + } + + /// Everyone left `file`: save it. True closes it (the next hello opens it again). + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + false + } + + /// The hub is stopping: save the open files. + fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} + + /// A managing connection's key: whether it may manage files. + fn manage(&mut self, _key: &str) -> Result<(), String> { + Err("this session is hosted by an app: it has no files to manage".into()) + } + + /// A request on a managing connection; `open` tells about a file that is open. + fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// A request on a library connection to `file`, opened with a link of `access` (checked by + /// [`admit`](Self::admit)). + fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { + LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } + } + + /// A request on a plugin directory connection; `publisher`: it was opened with the right key + /// (checked by [`manage`](Self::manage)). + fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { + PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } + } + + /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the + /// client). + fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { + Ok(()) + } + + /// A message from a connection from `ip`: false disconnects it (that address sends too much). + fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { + true + } + + /// What to tell a client that reads it about the server's hosting, after its welcome to + /// `file` (`None`: nothing). + fn hosting(&mut self, _file: &str) -> Option { + None + } + + /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: + /// what the server says about its hosting, or why the connection is refused. + fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { + Err("this session is hosted by an app: it doesn't keep files for anyone".into()) + } + + /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it + /// was opened with. + fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. + fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { + Err("this session is hosted by an app: tell the person hosting it".into()) + } + + /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not + /// (shown to them; wrong codes are counted against the address). + fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { + Err("this session is hosted by an app: join it with its own code or invite link".into()) + } + + /// What everyone is told when the hub stops. + fn closing(&self) -> Closing; +} diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/hub/barred_tests.rs similarity index 100% rename from crates/collab-net/src/barred_tests.rs rename to crates/collab-net/src/hub/barred_tests.rs diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs new file mode 100644 index 000000000..895218c45 --- /dev/null +++ b/crates/collab-net/src/hub/conn.rs @@ -0,0 +1,408 @@ +use super::hosting::{Open, spawn}; +use super::limits::{PerAddress, ip_key}; +use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block}; +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); + +/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; +/// beyond that they are dropped. +pub const SIGNAL_RATE: f64 = 20.0; + +pub const SIGNAL_BURST: f64 = 40.0; + +/// Longest request line an uptime check may have. +const MAX_CHECK_LINE: usize = 2048; + +/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. +/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket +/// handshake goes on untouched. +fn health_check(stream: &mut TcpStream, until: Instant) -> bool { + let mut buf = [0u8; MAX_CHECK_LINE]; + let line = loop { + let n = match stream.peek(&mut buf) { + Ok(0) | Err(_) => return false, + Ok(n) => n, + }; + let seen = buf.get(..n).unwrap_or_default(); + if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { + return false; + } + if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { + break seen.get(..end).unwrap_or_default().to_vec(); + } + if n == buf.len() || Instant::now() >= until { + return false; + } + // The rest of the line is on its way (it nearly always arrives in one piece). + std::thread::sleep(Duration::from_millis(10)); + }; + let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); + let path = path.split(|b| *b == b'?').next().unwrap_or_default(); + if !path.ends_with(b"/healthz") { + return false; + } + // Read the request (its headers end with a blank line), so closing doesn't reset it. + let mut req = Vec::new(); + let mut chunk = [0u8; 1024]; + while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), + } + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); + let _ = stream.shutdown(std::net::Shutdown::Write); + true +} + +/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the +/// proxy added; any before it are whatever the client sent). +fn forwarded_for(req: &Request) -> Option { + let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; + let entry = last.rsplit(',').next()?.trim(); + entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) +} + +/// What every connection shares: the count per address, the bytes queued for all of them and the +/// bytes in flight to the hub. +#[derive(Clone)] +pub(super) struct Shared { + pub(super) per_address: PerAddress, + pub(super) queued: Arc, + pub(super) in_flight: InFlight, + pub(super) blocks: Blocks, + /// Bytes being read into messages, all connections together, and who is reading them. + pub(super) reading: Arc, + pub(super) readers: Readers, +} + +pub(super) fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { + let mut next = 0u64; + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + let stream = match stream { + Ok(s) => s, + Err(e) => { + // Out of sockets or similar: don't spin. + log::warn!("live session: accept failed: {e}"); + std::thread::sleep(Duration::from_millis(50)); + continue; + } + }; + if open.load(Ordering::Relaxed) >= limits.max_connections { + log::info!("live session: too many connections; refusing one"); + continue; + } + open.fetch_add(1, Ordering::Relaxed); + let guard = Open(open.clone()); + next += 1; + let (conn, hub, shared) = (next, hub.clone(), shared.clone()); + if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { + log::warn!("live session: can't start a connection thread: {e}"); + } + } +} + +/// A close reason that fits a WebSocket close frame (123 bytes). +pub(super) fn reason(s: &str) -> String { + let mut end = s.len().min(123); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or_default().to_string() +} + +pub(super) fn encode(msg: &ServerMsg) -> Option { + match message::encode(msg) { + Ok(b) => Some(Bytes::from(b)), + Err(e) => { + log::warn!("live session: can't encode a message: {e}"); + None + } + } +} + +/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). +pub(super) fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +pub(super) fn refused(out: &Line, why: &str) -> bool { + if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { + let _ = out.tx.try_send(Out::Bytes(b, None)); + } + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() +} + +/// What the server tells a client when it is too busy to take more from it. +const BUSY: &str = "the server is busy: try again in a moment"; + +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { + loop { + return match ws.read() { + Ok(Message::Binary(b)) => { + // Whole now: it counts as in flight from here, no longer as being read. + ws.get_mut().read_done(); + message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) + } + Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, + Ok(Message::Close(_)) => Ok(None), + Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), + // The reading budget's refusal (see `Meter`). + Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), + Err(_) => Ok(None), + }; + } +} + +/// Why a message over `max` bytes is refused. +fn too_large(max: usize) -> String { + match max >> 20 { + 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), + mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), + } +} + +/// One connection's reader (the writer runs on its own thread). +fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { + let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; + if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { + return; + } + if health_check(&mut stream, Instant::now() + limits.hello_timeout) { + return; + } + let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; + let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); + let cfg = config(limits.max_client_message); + let mut forwarded = None; + // The handshake callback's type is tungstenite's (its error is an HTTP response). + #[allow(clippy::result_large_err)] + let read_client = |req: &Request, resp: Response| -> Result { + if proxied { + forwarded = forwarded_for(req); + } + Ok(resp) + }; + let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { + Ok(ws) => ws, + Err(e) => { + log::debug!("live session: handshake failed: {e}"); + shutdown(&sock); + return; + } + }; + if proxied && forwarded.is_none() { + log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); + } + let ip = ip_key(forwarded.unwrap_or(peer)); + let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); + let wide = wide_block(ip); + let block = shared.blocks.get(wide); + ws.get_mut().meter(Meter::new( + conn, + &sock, + &shared.readers, + block.reading.clone(), + shared.reading.clone(), + limits.max_reading_block, + limits.max_reading_total, + )); + let (tx, rx) = sync_channel::(limits.queue.max(4)); + let out = Line { + tx, + queued: Arc::default(), + block: block.queued.clone(), + all: shared.queued.clone(), + max: limits.max_queued_bytes, + max_block: limits.max_queued_block, + max_all: limits.max_queued_total, + }; + let writer = + match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; + let end = |why: Option<&str>| { + if let Some(why) = why { + refuse(&out, why); + } else { + let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); + } + }; + if counted.is_none() { + log::info!("event=refused ip={ip} reason=connections_per_address"); + end(Some("too many connections from your address: close some and try again")); + } else { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { + Ok(Some(( + first @ (ClientMsg::Hello(_) + | ClientMsg::Manage(_) + | ClientMsg::OpenLibrary(_) + | ClientMsg::OpenPlugins(_) + | ClientMsg::OpenHosting(_) + | ClientMsg::Resolve(_)), + bytes, + ))) => { + // Sessions and the operator may use the last connections; one-shot requests + // (hosting, libraries, browsing plugins) leave them free. + let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; + let max = match &first { + ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, + _ => limits.max_client_message, + }; + let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); + // (In flight is only taken when the connection goes on: the hub gives it back.) + if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { + end(Some(BUSY)); + } else { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); + read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); + let _ = hub.send(HubIn::Gone { conn }); + } + } + } + Ok(Some(_)) => end(Some("say hello first")), + Ok(None) => {} + Err(why) => end(Some(&why)), + } + } + drop(counted); + // The block's counters go with the last handle on them (the table forgets it then). + drop(block); + ws.get_mut().read_until(None); + linger(&mut ws); + drop(out); + // The writer closes the socket once it has sent what's queued (a refusal, a close). + let _ = writer.join(); + shutdown(&sock); +} + +fn read_loop( + ws: &mut WebSocket, + conn: u64, + hub: &Sender, + limits: NetLimits, + max: usize, + in_flight: &InFlight, + end: &dyn Fn(Option<&str>), +) { + let mut bucket = Bucket::new(limits.rate, limits.burst); + let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); + loop { + match next_message(ws, max, None) { + Ok(Some((msg, bytes))) => { + let now = Instant::now(); + if !bucket.take(now) { + log::info!("live session: connection {conn} sends too much; disconnecting it"); + return end(Some("too many messages")); + } + // Typing sends a chat signal per keystroke; more than this is dropped. + if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { + log::debug!("live session: connection {conn} sends signals too fast; dropping one"); + continue; + } + if !in_flight.take(bytes, limits.max_in_flight) { + log::info!("live session: the hub is behind; dropping connection {conn}"); + return end(Some(BUSY)); + } + if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { + return; + } + } + Ok(None) => return end(None), + Err(why) => return end(Some(&why)), + } + } +} + +fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { + let mut close = Some((CloseCode::Normal, String::new())); + while let Ok(o) = rx.recv() { + match o { + Out::Bytes(b, _charge) => { + if ws.send(Message::Binary(b)).is_err() { + close = None; + break; + } + } + Out::Close(code, why) => { + close = Some((code, why)); + break; + } + } + } + if let Some((code, why)) = close { + // Close politely: the close frame, then no more writing; the reader drains what the peer + // still sends until it hangs up (closing on unread data would reset the connection and + // could lose the close frame), or until the linger runs out. + let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); + let _ = sock.shutdown(std::net::Shutdown::Write); + let until = Instant::now() + LINGER; + while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { + if Instant::now() >= until { + break; + } + } + } + shutdown(sock); +} + +/// Read and drop what the peer still sends after the close, until it hangs up (at most +/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader +/// is mid-frame and mustn't be asked for more. +fn linger(ws: &mut WebSocket) { + let until = Instant::now() + LINGER; + let mut buf = [0u8; 4096]; + while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { + match ws.get_mut().read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} + Err(_) => break, + } + } +} diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs new file mode 100644 index 000000000..df8fd9756 --- /dev/null +++ b/crates/collab-net/src/hub/hosting.rs @@ -0,0 +1,204 @@ +use super::backend::Backend; +use super::conn::{Shared, accept}; +use super::limits::{PerAddress, loopback}; +use super::queue::{Blocks, HubIn, InFlight}; +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 { + pub file: String, + pub server: Server, + /// Their client id (they joined `server` already). + pub id: u32, + pub inbox: Inbox, +} + +pub(super) fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { + std::thread::Builder::new().name(name.to_string()).spawn(f) +} + +/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. +pub(crate) fn clean_name(s: &str) -> String { + s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() +} + +/// A running hub: its listener, connections and hub thread. Dropping it stops it. +pub struct Hosting { + addr: SocketAddr, + hub: Sender, + stop: Arc, + open: Arc, + /// Bytes queued for every connection together, and in flight to the hub. + queued: Arc, + in_flight: InFlight, + blocks: Blocks, + reading: Arc, + hub_thread: Option>, + accept_thread: Option>, +} + +impl Hosting { + /// Serve `backend`'s files on `listener`. + pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { + Self::start(listener, backend, limits, None) + } + + pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { + let addr = listener.local_addr()?; + let in_flight = InFlight(Arc::default()); + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; + if let Some(l) = local { + let mut room = Room::new(l.server); + room.local = Some((l.id, l.inbox)); + hub.rooms.insert(l.file.clone(), room); + hub.local = Some(l.file); + } + let (hub_tx, hub_rx) = channel(); + let stop = Arc::new(AtomicBool::new(false)); + let open = Arc::new(AtomicUsize::new(0)); + let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; + let shared = Shared { + per_address: PerAddress::default(), + queued: Arc::default(), + in_flight: in_flight.clone(), + blocks: Blocks::default(), + reading: Arc::default(), + readers: Readers::default(), + }; + let queued = shared.queued.clone(); + let blocks = shared.blocks.clone(); + let reading = shared.reading.clone(); + let accept_thread = { + let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); + spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) + }; + let accept_thread = match accept_thread { + Ok(t) => t, + Err(e) => { + let _ = hub_tx.send(HubIn::Stop); + let _ = hub_thread.join(); + return Err(e); + } + }; + log::info!("live session: listening on {addr}"); + Ok(Hosting { + addr, + hub: hub_tx, + stop, + open, + queued, + in_flight, + blocks, + reading, + hub_thread: Some(hub_thread), + accept_thread: Some(accept_thread), + }) + } + + /// Bytes waiting to be sent to clients, all connections together. + pub fn queued_bytes(&self) -> usize { + self.queued.load(Ordering::Relaxed) + } + + /// Blocks of addresses the server keeps counters for (those of open connections, and any + /// whose last connection just ended, until the next connection prunes them). + pub fn blocks_remembered(&self) -> usize { + self.blocks.len() + } + + /// Bytes being read into messages not whole yet, all connections together. + pub fn reading_bytes(&self) -> usize { + self.reading.load(Ordering::Relaxed) + } + + /// Bytes of messages read and not handled by the hub yet. + pub fn in_flight_bytes(&self) -> usize { + self.in_flight.0.load(Ordering::Relaxed) + } + + /// Where it listens. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Open connections (joined or not). + pub fn connections(&self) -> usize { + self.open.load(Ordering::Relaxed) + } + + /// The hosting person's presence (app sessions). + pub(crate) fn presence(&self, p: Presence) { + let _ = self.hub.send(HubIn::Local(p)); + } + + /// A message from the hosting person's sync client (their edits). + pub(crate) fn send(&self, msg: ClientMsg) { + let _ = self.hub.send(HubIn::LocalMsg(msg)); + } + + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + + /// Cut every connection as a network failure would (no close frame). + #[cfg(test)] + pub(crate) fn cut_connections(&self) { + let _ = self.hub.send(HubIn::Cut); + } + + /// Stop: the backend saves what's open, everyone is told and disconnected, the port is + /// released. + pub fn stop(&mut self) { + if self.stop.swap(true, Ordering::SeqCst) { + return; + } + let _ = self.hub.send(HubIn::Stop); + if let Some(t) = self.hub_thread.take() { + let _ = t.join(); + } + // The accept loop checks the flag after each connection: wake it with one. + let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; + let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); + if let Some(t) = self.accept_thread.take() { + if woke { + let _ = t.join(); + } else { + log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); + } + } + // Readers end once their writers have closed the sockets. + let deadline = Instant::now() + Duration::from_secs(2); + while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + log::info!("live session: stopped listening on {}", self.addr); + } +} + +impl Drop for Hosting { + fn drop(&mut self) { + self.stop(); + } +} + +/// Counts an open connection until dropped. +pub(super) struct Open(pub(super) Arc); + +impl Drop for Open { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs new file mode 100644 index 000000000..663697906 --- /dev/null +++ b/crates/collab-net/src/hub/limits.rs @@ -0,0 +1,99 @@ +use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr}; + +/// What a link lets a hello do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Admission { + pub access: Access, + /// The link's id, when the backend has several per file (revoking it disconnects whoever + /// joined with it). + pub link: Option, +} + +/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address +/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). +pub fn ip_key(ip: IpAddr) -> IpAddr { + match ip.to_canonical() { + IpAddr::V6(v6) => { + let [a, b, c, d, ..] = v6.segments(); + IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) + } + v4 => v4, + } +} + +/// The loopback address of `addr`'s family. +pub(super) fn loopback(addr: SocketAddr) -> std::net::IpAddr { + if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } +} + +/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a +/// /48 are different blocks). +pub type Block = (IpAddr, u8); + +/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each +/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 +/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. +pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { + let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; + let [a, b, c, d, ..] = v6.segments(); + vec![ + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), + ] +} + +/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address +/// leaves when its last one closes, so the table never holds more than the open connections. +#[derive(Clone, Default)] +pub(super) struct PerAddress(pub(super) Arc>>); + +/// One connection counted for a block until dropped. +pub(super) struct Counted { + table: PerAddress, + ip: Block, +} + +impl PerAddress { + /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). + fn enter(&self, ip: Block, cap: usize) -> Option { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let n = t.entry(ip).or_insert(0); + if cap > 0 && *n >= cap { + if *n == 0 { + t.remove(&ip); + } + return None; + } + *n += 1; + Some(Counted { table: self.clone(), ip }) + } + + /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider + /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of + /// `all` connections, so no block takes every connection. + pub(super) fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { + let mut held = Vec::new(); + for ((addr, prefix), times) in ip_blocks(ip) { + let block_cap = match prefix { + 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), + 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), + _ => cap, + }; + held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); + } + Some(held) + } +} + +impl Drop for Counted { + fn drop(&mut self) { + let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(n) = t.get_mut(&self.ip) { + *n = n.saturating_sub(1); + if *n == 0 { + t.remove(&self.ip); + } + } + } +} diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/hub/preauth_tests.rs similarity index 100% rename from crates/collab-net/src/preauth_tests.rs rename to crates/collab-net/src/hub/preauth_tests.rs diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs new file mode 100644 index 000000000..1c6a5dfcf --- /dev/null +++ b/crates/collab-net/src/hub/queue.rs @@ -0,0 +1,212 @@ +use super::limits::ip_blocks; +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. +pub(crate) enum Out { + Bytes(Bytes, Option), + Close(CloseCode, String), +} + +/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted +/// once against a shared counter (the server's, or a block's) until the last copy is sent or +/// dropped. +pub(super) struct Held { + n: usize, + counter: Arc, +} + +impl Held { + fn new(n: usize, counter: &Arc) -> Arc { + counter.fetch_add(n, Ordering::Relaxed); + Arc::new(Held { n, counter: counter.clone() }) + } +} + +impl Drop for Held { + fn drop(&mut self) { + self.counter.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// What one message counts against, made by its first recipient and shared by the others: the +/// server's budget once, and each recipient block's once (twenty people behind one office's +/// address cost their block one copy of a broadcast, not twenty). +#[derive(Default)] +pub(crate) struct Charges { + pub(super) all: Option>, + pub(super) blocks: Vec<(usize, Arc)>, + /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each + /// connection's cap, and a whole office joining at once must fit). + pub(crate) skip_blocks: bool, +} + +/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through +/// [`Held`], once against the server's budget and once against its block's). +pub(super) struct Charge { + n: usize, + mine: Arc, + _all: Arc, + _block: Option>, +} + +impl Drop for Charge { + fn drop(&mut self) { + self.mine.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// Why a message couldn't be queued. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Push { + /// The connection's or its block's queue is full: it can't keep up. + Full, + /// The server's budget for queued bytes is used up. + Everyone, + /// The connection is gone. + Gone, +} + +/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its +/// block of addresses', and the server's for everyone), so a client that doesn't read can't make +/// the server hold more than that for it. +#[derive(Clone)] +pub(crate) struct Line { + pub(crate) tx: SyncSender, + pub(crate) queued: Arc, + pub(crate) block: Arc, + pub(crate) all: Arc, + pub(crate) max: usize, + pub(crate) max_block: usize, + pub(crate) max_all: usize, +} + +impl Line { + /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file + /// gets out). `charges` is what the message counts against, shared by its recipients; + /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer + /// to make room). + pub(crate) fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { + let n = b.len(); + let mine = self.queued.load(Ordering::Relaxed); + if mine > 0 && mine.saturating_add(n) > self.max { + return Err(Push::Full); + } + let key = Arc::as_ptr(&self.block) as usize; + let block = if charges.skip_blocks { + None + } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { + Some(h.clone()) + } else { + let used = self.block.load(Ordering::Relaxed); + if used > 0 && used.saturating_add(n) > self.max_block { + return Err(Push::Full); + } + let h = Held::new(n, &self.block); + charges.blocks.push((key, h.clone())); + Some(h) + }; + let all = match &charges.all { + Some(h) => h.clone(), + None => { + if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { + return Err(Push::Everyone); + } + charges.all.insert(Held::new(n, &self.all)).clone() + } + }; + self.queued.fetch_add(n, Ordering::Relaxed); + let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; + self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { + TrySendError::Full(_) => Push::Full, + TrySendError::Disconnected(_) => Push::Gone, + }) + } +} + +/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is +/// having read, shared by its connections. +#[derive(Clone, Default)] +pub(crate) struct BlockUse { + pub(super) queued: Arc, + pub(super) reading: Arc, +} + +/// The blocks with open connections; a block leaves when its last connection ends. +#[derive(Clone, Default)] +pub(crate) struct Blocks(pub(crate) Arc>>); + +impl Blocks { + /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, + /// whose connections' queues and readers are all gone) are forgotten first, so the table + /// never holds more than the blocks of open connections. + pub(crate) fn get(&self, key: IpAddr) -> BlockUse { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); + t.entry(key).or_default().clone() + } + + /// Blocks remembered. + pub(crate) fn len(&self) -> usize { + self.0.lock().unwrap_or_else(PoisonError::into_inner).len() + } +} + +/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an +/// IPv6 address's /48. +pub fn wide_block(key: IpAddr) -> IpAddr { + ip_blocks(key).last().map_or(key, |(b, _)| b.0) +} + +/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader +/// drops its connection instead of piling more on a busy hub. +#[derive(Clone)] +pub(crate) struct InFlight(pub(crate) Arc); + +impl InFlight { + pub(super) fn take(&self, n: usize, max: usize) -> bool { + if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { + self.0.fetch_sub(n, Ordering::Relaxed); + return false; + } + true + } + + pub(super) fn give(&self, n: usize) { + self.0.fetch_sub(n, Ordering::Relaxed); + } +} + +pub(super) enum HubIn { + /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, + /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). + Open { + conn: u64, + ip: IpAddr, + first: ClientMsg, + out: Line, + sock: TcpStream, + /// Its size (in flight until handled). + bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, + }, + Msg { + conn: u64, + msg: ClientMsg, + bytes: usize, + }, + Gone { + conn: u64, + }, + Local(Presence), + LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + /// Cut every connection without a word (tests: a network failure). + #[cfg(test)] + Cut, + Stop, +} diff --git a/crates/collab-net/src/queue_tests.rs b/crates/collab-net/src/hub/queue_tests.rs similarity index 100% rename from crates/collab-net/src/queue_tests.rs rename to crates/collab-net/src/hub/queue_tests.rs diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 0321b36c1..301c4e78d 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -30,7 +30,7 @@ pub struct NetLimits { /// answer, so this is longer than `idle_timeout` (a large project on a slow connection can /// easily need several minutes). pub request_reply_timeout: Duration, - /// A quiet client pings this often (keeps it under `idle_timeout`). + /// A client pings this often, whatever else it sends (keeps it under `idle_timeout`). pub keepalive: Duration, /// Messages queued for one client before it counts as stuck and is disconnected. pub queue: usize, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index ed85201dc..189c1a02c 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -17,6 +17,7 @@ chrono = { workspace = true } imbl = { workspace = true } base64 = { workspace = true } postcard = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] figcraft-color = { workspace = true } diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 7977f83a2..dedae208a 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -245,6 +245,10 @@ pub enum Entry { /// [`DevSnapshot`](figcraft_doc::DevSnapshot)). DevSnapshot(NodeId), /// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64). + /// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 + /// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain + /// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte + /// size are bounded as the entry applies (`image_of` in `docprops.rs`). Image(String), /// A voter's ballot in a board's vote (value: the layer ids voted on). Ballot(String), @@ -309,6 +313,13 @@ impl DocProp { } } +impl Change { + /// Whether this puts or removes an image entry. + pub(crate) fn is_image(&self) -> bool { + matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) }) + } +} + impl Entry { /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 24a604ffb..f53040fbf 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -8,6 +8,7 @@ use base64::Engine as _; use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; use crate::Error; use crate::change::{Change, DocProp, Entry, Family, Json}; @@ -164,13 +165,68 @@ struct ImageValue { bytes: String, } +/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no +/// image this app realistically opens reaches it — an entry claiming a bigger side is +/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it +/// couldn't probe (`image_data`). +pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535; + +/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces. +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that +/// already holds an image can't be given other bytes. A key always names the same bytes +/// (producers key images by their bytes; the REST importer's placeholder keys always hold the +/// same grey picture), so nobody can swap the picture under a key someone else added. +fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result { + let img = image_of(key, value)?; + if held.is_some_and(|old| old.bytes != img.bytes) { + return Err(Error::Invalid(format!("image: {key} already holds other bytes"))); + } + Ok(img) +} + +/// The JSON value an image entry carries: its size and its encoded bytes in base64. fn image_json(img: &ImageData) -> Result { Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) }) } -fn image_of(value: &Json) -> Result { +/// The image a change carries, validated as a collaborator's claim (an invalid entry +/// rejects its batch, leaving every replica's store clean): a key that can name an archive +/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/`), dimensions within +/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read; +/// the encoded length is capped before decoding so hostile text can't force a large +/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must +/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash` +/// writes — a poisoned content-addressed store would show the wrong image everywhere at once +/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders) +/// claim no content address, so only the name and size rules apply to them. +fn image_of(key: &str, value: &Json) -> Result { + image_within(key, value, ImageData::MAX_BYTES) +} + +/// [`image_of`] with `max_bytes` for the decoded size. +pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result { + if !ImageData::is_valid_key(key) { + return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)"))); + } let v: ImageValue = value.read("image")?; + if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE { + return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE))); + } + let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4); + if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded { + return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes"))); + } let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len()))); + } + if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) { + return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to"))); + } Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height }) } @@ -286,7 +342,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result doc.dev_snapshots.insert(*k, Arc::new(snap)); } Entry::Image(k) => { - let img = image_of(value)?; + let img = image_for(doc.images.get(k), k, value)?; doc.images.insert(k.clone(), img); } Entry::Ballot(k) => { @@ -302,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result doc.dev_snapshots.remove(k); } Entry::Image(k) => { + // An image a layer or style shows stays: removing it would let other bytes go + // under its key, swapping the picture that layer shows ([`image_for`]). + if figcraft_doc::used_images(doc).contains(k) { + return Err(Error::Invalid(format!("image: {k} is still in use"))); + } doc.images.remove(k); } Entry::Ballot(k) => { diff --git a/crates/collab/src/history.rs b/crates/collab/src/history.rs index a45e88a43..50f09c27f 100644 --- a/crates/collab/src/history.rs +++ b/crates/collab/src/history.rs @@ -311,17 +311,7 @@ impl Step { apply_all(&mut work, &mut out, deletes); // Changes that apply one by one can still fail the batch's checks together (rare): keep // only those that pass them in order. - if view.clone().apply(&out, Mode::Strict).is_err() { - let mut w = view.clone(); - out.retain(|c| { - let mut t = w.clone(); - let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok(); - if ok { - w = t; - } - ok - }); - } + view.clone().apply_kept(&mut out, |_| true)?; Ok(out) } } diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs new file mode 100644 index 000000000..9588a68c6 --- /dev/null +++ b/crates/collab/src/image_tests.rs @@ -0,0 +1,258 @@ +//! Image entries are checked as they apply: bytes carried under a content-hash key must be +//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash` +//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded +//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared +//! content-addressed image store or balloon memory (#1086). + +use base64::Engine as _; +use figcraft_doc::Document; +use serde_json::json; +use sha2::{Digest as _, Sha256}; + +use crate::change::{Change, Entry, Json}; +use crate::docprops::apply as apply_doc; +use crate::docprops::{MAX_IMAGE_SIDE, image_within}; + +/// The side bound the implementation enforces. +const MAX_SIDE: u32 = MAX_IMAGE_SIDE; + +/// sha256 hex, computed here independently of the implementation under test. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// An image entry putting `bytes` (`width` × `height`) under `key`. +pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change { + Change::Put { + entry: Entry::Image(key.into()), + value: Json(json!({ + "width": width, + "height": height, + "bytes": base64::engine::general_purpose::STANDARD.encode(bytes), + })), + } +} + +/// Bytes a collaborator hashed into the key, and different bytes they send under it. +pub(crate) fn forged() -> (String, &'static [u8]) { + (sha256_hex(b"the image the key actually names"), b"someone else's bytes") +} + +#[test] +fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() { + let (key, bytes) = forged(); + let mut doc = Document::new("t"); + let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) { + Ok(_) => panic!("bytes that don't hash to their key must be refused"), + Err(e) => e, + }; + assert!(e.to_string().contains("image"), "not an image refusal: {e}"); + assert!(doc.images.is_empty(), "the image store was poisoned under key {key}"); +} + +/// An uppercase key looks like a content hash but isn't one: producers store the lowercase +/// form, so bytes matching under the uppercase form would file a second copy under a name +/// no honest lookup reaches. Refused (#1086). +#[test] +fn an_uppercase_content_hash_shaped_key_is_refused() { + let bytes = b"honestly keyed, uppercase claim".as_slice(); + let mut doc = Document::new("t"); + let upper = sha256_hex(bytes).to_uppercase(); + let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) { + Ok(_) => panic!("an uppercase key claims a content address it doesn't have"), + Err(e) => e, + }; + assert!(e.to_string().contains("image"), "not an image refusal: {e}"); + assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy"); +} + +#[test] +fn an_image_that_hashes_to_its_key_applies() { + let bytes = b"a real image, honestly keyed".as_slice(); + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged"); + let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored"); + assert_eq!((img.width, img.height), (2, 1)); + assert_eq!(img.bytes.as_slice(), bytes); +} + +#[test] +fn absurd_dimensions_are_refused() { + let bytes = b"honest bytes, dishonest size".as_slice(); + let key = sha256_hex(bytes); + for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] { + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(&key, w, h, bytes)) + .err() + .unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len())); + assert!(doc.images.is_empty()); + } +} + +/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked +/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps +/// the test light. +#[test] +fn oversized_image_bytes_are_refused() { + let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)}); + let at = vec![0u8; 1024]; + image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies"); + let past = vec![0u8; 1025]; + assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap"); + let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)}); + let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}"); +} + +#[test] +fn a_zero_sized_image_still_applies() { + // The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`), + // so a shared document can legitimately carry one; the bytes still have to be honest. + let bytes = b"undecodable bytes kept from a file load".as_slice(); + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd"); + assert!(doc.images.contains_key(&sha256_hex(bytes))); +} + +#[test] +fn a_key_that_is_not_a_content_hash_shape_still_applies() { + // Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex + // chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the + // imageRef itself. They are not content-addressed references, so no hash is claimed; the + // dimension and size bounds still hold. + for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] { + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}")); + assert!(doc.images.contains_key(key)); + let mut big = Document::new("t"); + assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions"); + } +} + +/// A key becomes an archive member name when the file is saved (`images/`), so a key that +/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write +/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086). +#[test] +fn a_key_that_cannot_name_an_archive_member_is_refused() { + for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] { + let mut doc = Document::new("t"); + assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused"); + assert!(doc.images.is_empty(), "key {key:?} reached the image store"); + } +} + +/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a +/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session. +#[test] +fn an_image_as_large_as_an_import_takes_still_applies() { + let bytes = vec![0u8; 40 << 20]; + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read"); + assert_eq!(doc.images.len(), 1); +} + +/// An image key is content-derived by every producer, so its bytes never change: a collaborator +/// can't swap the picture under a key another one added. +#[test] +fn an_image_cannot_be_replaced_under_its_key() { + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap(); + assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key"); + assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice())); + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)"); +} + +/// A snapshot written before these checks may hold an image they refuse: the session still +/// opens, without that image, instead of not opening at all. +#[test] +fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() { + let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap(); + let (key, bytes) = forged(); + snapshot.push(put(&key, 2, 1, bytes)); + let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens"); + assert!(!r.document().images.contains_key(&key)); +} + +/// A server's log written before these checks may hold an image they refuse: a restart replays +/// the edits after it instead of dropping them with the rest of the log. +#[test] +fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() { + let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default()); + let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap(); + let (key, bytes) = forged(); + let good = b"an honest image after it".as_slice(); + let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes }); + for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])] + { + r.replay(j).expect("the log replays"); + } + assert_eq!(r.seq(), 2, "both batches count"); + assert!(!r.document().images.contains_key(&key), "the refused image is left out"); + assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept"); +} + +/// A server's log written before these checks may give an image other bytes under its key (the +/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and +/// every edit after it, instead of stopping there and dropping the rest of the log. +#[test] +fn a_restart_replays_past_a_logged_image_replacement() { + let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default()); + let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap(); + let good = b"an honest image after it".as_slice(); + let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes }); + let journal = [ + crate::Journal::Issued { client: 1 }, + accepted(1, vec![put("img1", 2, 1, b"the first bytes")]), + accepted(2, vec![put("img1", 2, 1, b"other bytes")]), + accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]), + ]; + for j in journal { + r.replay(j).expect("the log replays"); + } + assert_eq!(r.seq(), 3, "every batch counts"); + assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out"); + assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept"); +} + +/// A document whose rectangle shows `img1` (the first bytes), and that rectangle. +fn in_use() -> (Document, figcraft_doc::NodeId) { + let mut doc = Document::new("t"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)]; + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap(); + (doc, id) +} + +/// An image a layer or style still shows can't be removed: removing it and putting other bytes +/// under its key would swap the picture that layer shows, which giving the key other bytes +/// directly can't do (#1086). +#[test] +fn an_image_in_use_cannot_be_removed() { + let (mut doc, _) = in_use(); + let remove = Change::Remove { entry: Entry::Image("img1".into()) }; + assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it"); + assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err()); + assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice())); +} + +/// A batch that first stops using an image, then removes it (the order a diff makes), applies. +#[test] +fn an_image_no_longer_in_use_can_be_removed() { + let (doc, id) = in_use(); + let mut r = crate::Replica::new(doc); + let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }]; + r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image"); + assert!(r.document().images.is_empty()); +} + +/// Only image entries are left out of saved history: a snapshot whose other changes don't apply +/// is still an error, not a document missing them. +#[test] +fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() { + let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap(); + // A property of a layer that isn't there: an error when applied (unlike deleting it). + let missing = figcraft_doc::NodeId(u64::MAX); + snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) }); + assert!(crate::Replica::from_snapshot(&snapshot).is_err()); +} diff --git a/crates/collab/src/journal_tests.rs b/crates/collab/src/journal_tests.rs index 6e41ee76e..a8a7c4c25 100644 --- a/crates/collab/src/journal_tests.rs +++ b/crates/collab/src/journal_tests.rs @@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() { assert_eq!(errors.len(), 1, "the batch after the gap"); assert_eq!(r.seq(), 0); } + +/// Restores `state` with one more batch from another client in its log (and in its snapshot, +/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it. +fn resume_past(s: &Server, a: &mut Client, batch: Vec, in_snapshot: bool) -> Server { + let mut state = s.state().unwrap(); + if in_snapshot { + state.snapshot.retain(|c| { + !batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y)) + }); + state.snapshot.extend(batch.iter().cloned()); + } + state.seq += 1; + state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch }); + let mut r = Server::restore(state, "", Limits::default()).unwrap(); + a.disconnect(); + let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") }; + let (_, out) = r.join(h).unwrap(); + deliver(a, &out); + r +} + +/// A saved log may hold an image a newer server refuses: a client resuming from before it still +/// catches up, without that image (#1086). +#[test] +fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let (key, bytes) = crate::image_tests::forged(); + let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false); + assert!(!a.view().document().images.contains_key(&key)); + assert_eq!(a.view(), r.replica()); +} + +/// A saved log may give an image other bytes under a key the resuming client already holds (an +/// older server allowed it): the client still catches up, to what the server holds (#1086). +#[test] +fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let m = edit(&mut a, |d| { + d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 }); + }); + send(&mut s, a.id(), m, &mut [&mut a]); + let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true); + assert_eq!(a.view(), r.replica()); +} + +/// `a` adds a rectangle showing image `img1`; returns the rectangle. +fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId { + let page = s.document().first_page().unwrap(); + let mut id = None; + let m = edit(a, |d| { + let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)]; + d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 }); + id = Some(r); + }); + send(s, a.id(), m, &mut [a]); + id.unwrap() +} + +/// Undoing an image's insertion still works with images in use protected from removal (#1086). +#[test] +fn undoing_an_image_layer_still_works() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let id = add_image_layer(&mut s, &mut a); + for m in a.undo(&NoRecompute).unwrap() { + send(&mut s, a.id(), m, &mut [&mut a]); + } + assert!(s.document().node(id).is_none(), "the layer is gone"); + assert_eq!(a.view(), s.replica()); +} + +/// A server's log written before the image checks may remove an image a layer still shows: a +/// restart replays past it, keeping the image and every edit after it (#1086). +#[test] +fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() { + let mut s = Server::new(base(), "", Limits::default()); + s.journaling(true); + let mut a = join_as(&mut s, Access::Edit); + add_image_layer(&mut s, &mut a); + let saved = s.state().unwrap(); + let page = s.document().first_page().unwrap(); + let mut journal = s.take_journal(); + let base_seq = saved.seq; + let next = |n: u64| base_seq + n; + journal.push(Journal::Accepted(message::Accepted { + seq: next(1), + author: a.id(), + batch_id: 90, + changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }], + })); + let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) }; + journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] })); + let mut r = Server::restore(saved, "", Limits::default()).unwrap(); + for j in journal { + r.replay(j).expect("the log replays"); + } + assert_eq!(r.seq(), next(2)); + assert!(r.document().images.contains_key("img1"), "the image a layer shows stays"); + assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept"); +} + +/// What a resuming client is told to catch up with. +fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp { + a.disconnect(); + let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") }; + let (id, out) = r.join(h).unwrap(); + out.into_iter() + .find_map(|o| match o { + Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up), + _ => None, + }) + .expect("a welcome") +} + +/// A restart that left an image out of the saved history (an older server accepted it) catches +/// every resuming client up with the snapshot, even one at the same sequence number: a client +/// that kept the image from before would otherwise go on showing what the session no longer +/// has (#1086). +#[test] +fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let mut state = s.state().unwrap(); + let (key, bytes) = crate::image_tests::forged(); + state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes)); + let mut r = Server::restore(state, "", Limits::default()).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore"); + + // Left out on replay. + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let state = s.state().unwrap(); + let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap(); + let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)]; + r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay"); +} + +/// A restart that left nothing out keeps resuming clients on the cheaper delta. +#[test] +fn a_resume_after_a_clean_restart_catches_up_with_the_log() { + let mut s = Server::new(base(), "", Limits::default()); + let mut a = join_as(&mut s, Access::Edit); + let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap(); + assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_))); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index fa271f35b..f256ef15d 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -136,6 +136,8 @@ mod entry_ids_tests; #[cfg(test)] mod frac_tests; #[cfg(test)] +mod image_tests; +#[cfg(test)] mod journal_tests; #[cfg(test)] mod merge_list_tests; diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs index e14c5fec8..0bf0391af 100644 --- a/crates/collab/src/replica.rs +++ b/crates/collab/src/replica.rs @@ -72,12 +72,47 @@ impl Replica { /// Rebuild a replica from a [`snapshot`](Self::snapshot). pub fn from_snapshot(changes: &[Change]) -> Result { + Self::from_snapshot_leaving_out(changes).map(|(r, _)| r) + } + + /// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a + /// snapshot saved before the image checks may hold one they refuse, and opens without it. + pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> { let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() }; - r.apply(changes, Mode::Strict)?; + let mut changes = changes.to_vec(); + let left_out = r.apply_kept(&mut changes, Change::is_image)?; if r.doc.pages.is_empty() { return Err(Error::Invalid("a snapshot needs a page".into())); } - Ok(r) + Ok((r, left_out)) + } + + /// Apply `changes` strictly. When they don't apply together — history written under older + /// rules, or changes that apply one by one but fail the batch's checks together — apply those + /// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out` + /// allows it (otherwise that change's error). Returns how many were left out. + pub(crate) fn apply_kept(&mut self, changes: &mut Vec, may_leave_out: impl Fn(&Change) -> bool) -> Result { + let mut all = self.clone(); + if all.apply(changes, Mode::Strict).is_ok() { + *self = all; + return Ok(0); + } + let given = changes.len(); + let mut kept = Vec::with_capacity(given); + for c in changes.drain(..) { + let mut t = self.clone(); + match t.apply(std::slice::from_ref(&c), Mode::Strict) { + Ok(_) => { + *self = t; + kept.push(c); + } + Err(e) if !may_leave_out(&c) => return Err(e), + Err(_) => {} + } + } + let left_out = given - kept.len(); + *changes = kept; + Ok(left_out) } /// The whole state as changes that build it from nothing (pages and their trees in order, diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 52f112ad6..9a04e54b3 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -77,6 +77,10 @@ pub struct Server { signals: BTreeSet, /// Who spotlights now (a newcomer is told). spotlight: Option, + /// Saved history this server restored or replayed left image entries out (or may give a + /// resuming client other bytes under a key it holds): every resuming client catches up with + /// the snapshot, not the log. + resync: bool, } /// The most client ids a session remembers for reconnecting (the oldest disconnected ones go @@ -127,6 +131,7 @@ impl Server { journal: None, signals: BTreeSet::new(), spotlight: None, + resync: false, } } @@ -148,7 +153,7 @@ impl Server { /// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped /// (clients then catch up with a snapshot). pub fn restore(state: SessionState, token: impl Into, limits: Limits) -> Result { - let replica = Replica::from_snapshot(&state.snapshot)?; + let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?; let mut server = Server::new(replica.document().clone(), token, limits); server.state = replica; server.session = state.session; @@ -158,6 +163,12 @@ impl Server { server.deleted = state.deleted; server.removed = state.removed; let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq)); + // History saved before the image checks may hold image entries they refuse. The snapshot + // opens without them, and a saved log putting images may give a resuming client other + // bytes under a key it holds, which this state can't check: either way resuming clients + // catch up with the snapshot. + let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image); + server.resync = left_out > 0 || puts_images; if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) { for a in state.log { server.remember(a); @@ -198,10 +209,16 @@ impl Server { Ok(()) } Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()), - Journal::Accepted(acc) => { + Journal::Accepted(mut acc) => { if acc.seq != self.seq + 1 { return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq))); } + // A log written before the image checks may hold an image entry they refuse: the + // batch replays without it, so the edits after it aren't dropped with the rest of + // the log, and resuming clients catch up with the snapshot. + if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 { + self.resync = true; + } self.accept(acc.author, self.seq, &acc.changes)?; self.issued.entry(acc.author).or_default().note(acc.batch_id, false); self.next_client = self.next_client.max(acc.author.saturating_add(1)); @@ -284,7 +301,9 @@ impl Server { // The log holds every batch after `floor`. let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1)); let catch_up = match resume { - Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()), + Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => { + CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()) + } _ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?), }; let (id, secret) = match resume { diff --git a/crates/doc/src/client_id_tests.rs b/crates/doc/src/client_id_tests.rs index 520895fb7..94b676c22 100644 --- a/crates/doc/src/client_id_tests.rs +++ b/crates/doc/src/client_id_tests.rs @@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() { assert!(d.validate().is_empty()); } +/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer +/// that isn't a page, and repairs into a file with a page. #[test] fn pages_can_be_inserted_onto_a_page_less_base() { let mut d = Document::without_pages("snapshot"); assert!(d.pages.is_empty() && d.node_count() == 0); let p = Node::new( NodeId(C3 | 1), - NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] }, + NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() }, "P", 0.0, 0.0, diff --git a/crates/doc/src/guides_tests.rs b/crates/doc/src/guides_tests.rs new file mode 100644 index 000000000..82926e5de --- /dev/null +++ b/crates/doc/src/guides_tests.rs @@ -0,0 +1,61 @@ +//! Ruler guides survive a save and reopen (#1476). + +use crate::{Document, Guide, Node, NodeKind}; + +/// A guide: vertical (at an x offset) or horizontal (at a y offset). +fn guide(vertical: bool, offset: f64) -> Guide { + Guide { vertical, offset } +} + +/// A page's guides are written once and read back onto the page. +#[test] +fn page_guides_round_trip_through_json() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0)); + let json = serde_json::to_string(d.node(page).unwrap()).unwrap(); + assert_eq!(json.matches("\"guides\"").count(), 1, "{json}"); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back.guides(), &[guide(true, 120.0)]); + assert_eq!(back, *d.node(page).unwrap()); +} + +/// A frame's guides round trip too, and a layer without guides writes no `guides` key. +#[test] +fn frame_guides_round_trip_and_none_are_left_out() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + for id in [page, frame] { + let json = serde_json::to_value(d.node(id).unwrap()).unwrap(); + assert!(json.get("guides").is_none(), "{json}"); + } + d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0)); + let json = serde_json::to_string(d.node(frame).unwrap()).unwrap(); + assert_eq!(json.matches("\"guides\"").count(), 1, "{json}"); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back, *d.node(frame).unwrap()); +} + +/// A page saved with two `guides` keys reads both lists, in file order, and writes them once. +#[test] +fn a_page_written_with_two_guides_lists_opens_with_both() { + // How a page was saved after its file had been reopened: the page kind's list (guides added + // since), then the layer's (the ones the file had). + let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}], + "width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#; + let n: Node = serde_json::from_str(json).unwrap(); + assert!(matches!(n.kind, NodeKind::Page { .. })); + assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]); + let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap(); + assert_eq!(again.guides(), n.guides()); +} + +/// A `guides` value that isn't a list of guides is still refused. +#[test] +fn a_bad_guides_list_is_still_an_error() { + for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] { + let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{bad}"); + } +} diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs index 79b971947..8a18151db 100644 --- a/crates/doc/src/handoff_tests.rs +++ b/crates/doc/src/handoff_tests.rs @@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() { doc.get_mut(page).unwrap().children.retain(|c| *c != kid); assert!(doc.validate().iter().any(|m| m.contains("not listed in parent"))); } + +/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per +/// page (#1078: a file with many pages made repair quadratic). +#[test] +fn repair_relists_many_unlisted_pages_once_each() { + let mut doc = Document::new("d"); + for i in 0..5_000 { + doc.add_page(&format!("P{i}")); + } + let all: Vec<_> = doc.pages.clone(); + // Half the pages fall off the list. + doc.pages.retain(|p| p.0 % 2 == 0); + doc.repair(); + let mut listed = doc.pages.clone(); + listed.sort_unstable(); + let mut want = all; + want.sort_unstable(); + assert_eq!(listed, want, "every page is listed again, once"); + assert!(doc.validate().is_empty()); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index caf0c861a..6f6af7a35 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -161,6 +161,26 @@ pub struct ImageData { pub height: u32, } +impl ImageData { + /// Largest encoded image a document takes from outside: an image member of a `.fig` or + /// `.sketch` file, an image file a REST import reads, an image entry from a live session. + pub const MAX_BYTES: u64 = 256 * 1024 * 1024; + + /// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex + /// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it. + pub fn claims_content_hash(key: &str) -> bool { + key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) + } + + /// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes, + /// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key + /// becomes an archive member name (`images/`) when the file is saved, so only these are + /// read back from a file or taken from a collaborator: never `/`, `.` or other path text. + pub fn is_valid_key(key: &str) -> bool { + (1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + } +} + /// A whole design file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { @@ -407,13 +427,8 @@ impl Document { /// Add a page at the end. pub fn add_page(&mut self, name: &str) -> NodeId { let id = self.alloc_id(); - let node = Node::new( - id, - NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() }, - name, - 0.0, - 0.0, - ); + let node = + Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0); self.nodes.insert(id, Arc::new(node)); self.dirty.touch(id); self.pages.push(id); @@ -1143,8 +1158,10 @@ impl Document { Arc::make_mut(&mut self.libraries).store = store; } self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store); + // Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078). + let mut listed: std::collections::BTreeSet = self.pages.iter().copied().collect(); for (k, n) in self.nodes.iter() { - if n.is_page() && !self.pages.contains(k) && Some(*k) != store { + if n.is_page() && Some(*k) != store && listed.insert(*k) { self.pages.push(*k); } } @@ -1258,6 +1275,8 @@ mod diff_tests; #[cfg(test)] mod effects_tests; #[cfg(test)] +mod guides_tests; +#[cfg(test)] mod handoff_tests; #[cfg(test)] mod ids_tests; diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 35994bb00..fe53ec29f 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -260,7 +260,7 @@ impl Document { let id = self.alloc_id(); let node = Node::new( id, - NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() }, + NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, STORE_NAME, 0.0, 0.0, diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2a284b057..bcf8f09d4 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; @@ -63,6 +66,9 @@ pub enum PropDef { } /// What a node is, with per-type data. +// Text sets the size; the lint counts the gap to the next largest variant, which grew only because +// Page's guides moved to the node (#1476). +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum NodeKind { @@ -73,8 +79,6 @@ pub enum NodeKind { flows: Vec, #[serde(default)] prototype: crate::proto::PrototypeSettings, - #[serde(default)] - guides: Vec, }, Frame, Group, @@ -469,8 +473,8 @@ pub struct Node { pub mask_type: MaskType, #[serde(default, skip_serializing_if = "is_default")] pub grids: Vec, - /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]). - #[serde(default, skip_serializing_if = "is_default")] + /// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]). + #[serde(flatten, with = "guides_key")] pub guides: Vec, #[serde(default, skip_serializing_if = "is_default")] pub exports: Vec, @@ -575,6 +579,77 @@ fn is_identity(v: &Affine) -> bool { *v == identity() } +/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is +/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the +/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two +/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order. +mod guides_key { + use super::Guide; + use serde::de::{IgnoredAny, MapAccess, Visitor}; + use serde::ser::SerializeMap; + use serde::{Deserialize, Deserializer, Serializer}; + use std::fmt; + + const KEY: &str = "guides"; + + /// Write `guides` as the layer's `guides` key, or nothing when there are none. + pub fn serialize(guides: &[Guide], s: S) -> Result { + let mut map = s.serialize_map(None)?; + if !guides.is_empty() { + map.serialize_entry(KEY, guides)?; + } + map.end() + } + + /// Read every `guides` list among the layer's fields, joined in file order. + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + d.deserialize_map(Lists) + } + + /// Reads the `guides` lists of a layer's fields and skips the other fields. + struct Lists; + + impl<'de> Visitor<'de> for Lists { + type Value = Vec; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a layer's fields") + } + /// Every `guides` value joined, in order; a malformed one is an error. + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let mut guides = Vec::new(); + while let Some(key) = map.next_key::()? { + if key.0 { + guides.extend(map.next_value::>()?); + } else { + map.next_value::()?; + } + } + Ok(guides) + } + } + + /// Whether a field name is `guides` (without copying the name). + struct Key(bool); + + impl<'de> Deserialize<'de> for Key { + /// Compare a field name with `guides` as it is read. + fn deserialize>(d: D) -> Result { + struct Name; + impl Visitor<'_> for Name { + type Value = Key; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a field name") + } + /// `guides` or another field. + fn visit_str(self, s: &str) -> Result { + Ok(Key(s == KEY)) + } + } + d.deserialize_identifier(Name) + } + } +} + impl Node { /// A node with Figma's defaults for its kind. pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self { @@ -709,17 +784,12 @@ impl Node { /// The ruler guides on this page or frame, in its own coordinates. pub fn guides(&self) -> &[Guide] { - match &self.kind { - NodeKind::Page { guides, .. } => guides, - _ => &self.guides, - } + &self.guides } + /// The ruler guides on this page or frame, to edit. pub fn guides_mut(&mut self) -> &mut Vec { - match &mut self.kind { - NodeKind::Page { guides, .. } => guides, - _ => &mut self.guides, - } + &mut self.guides } /// Children in paint order, bottom to top: the child list, reversed in an auto layout frame diff --git a/crates/engine/src/assets.rs b/crates/engine/src/assets.rs new file mode 100644 index 000000000..e648ad3b8 --- /dev/null +++ b/crates/engine/src/assets.rs @@ -0,0 +1,7 @@ +pub mod components; +pub mod library; +pub mod library_server; +pub mod library_swap; +pub mod playground; +pub mod style_vars; +pub mod token_roles; diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs new file mode 100644 index 000000000..3192c3f1f --- /dev/null +++ b/crates/engine/src/assets/components.rs @@ -0,0 +1,303 @@ +//! Components and instances. +//! +//! An instance's layers are real nodes copied from its main component; each copy records the +//! main-component node it mirrors (`source`) and which property groups were changed locally +//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer +//! ids and re-applying overrides (matched by source, then by layer name path, so switching +//! variants keeps compatible overrides). Variants are components inside a component set, named +//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through +//! `prop_refs` on the layers they control. +//! +//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, +//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` +//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, +//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` +//! overrides of layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their place and constraints from the main. +//! +//! Switching variants keeps an override where the layer's name path matches and the two variants +//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps +//! only text layers' overrides ([`keep_text_overrides`]). +//! +//! Instances never gain or lose layers of their own: commands that would add, remove or reorder +//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A +//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An +//! instance's copy of it shows the main's default content until its layers are edited; from then +//! on (override group `slot`) its children are the instance's own content: free to edit, kept +//! across syncs and variant switches, and outside the instance's structure (instances placed in it +//! stand alone). + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::{ + ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, +}; +use figcraft_geom::Affine; +use serde_json::Value; + +mod diff; +mod guard; +mod instances; +mod patch; +mod rebase; +mod sync; +mod variants; + +pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups}; +pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers}; +pub(crate) use instances::copy_group; +pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance}; +pub use patch::sync_after_edit; +#[cfg(test)] +pub(crate) use patch::{FULL_REBUILD, PATCHED}; +#[cfg(test)] +use rebase::RebaseSources; +pub use rebase::rebase_instance; +pub use sync::keep_text_overrides; +pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props}; + +/// Nesting limit for instances inside instances. +const MAX_DEPTH: usize = 16; +/// The override group (and `prop_refs` field) of slots. +pub const SLOT: &str = "slot"; +/// Every override group a layer can carry. +pub const OVERRIDE_GROUPS: &[&str] = &[ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "resizing", + "position", + "modes", + "component", + "props", + SLOT, +]; + +fn is_instance(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) +} + +/// A slot (inside an instance) whose children are the instance's own content. +fn holds_content(n: &Node) -> bool { + n.overrides.contains(SLOT) +} + +/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content: +/// content belongs to no instance (it edits freely, and instances placed in it stand alone). +fn scope(doc: &Document, id: NodeId) -> Vec { + let mut out = vec![id]; + for a in doc.ancestors(id) { + if doc.node(a).is_some_and(holds_content) { + break; + } + out.push(a); + } + out +} + +/// The component a node belongs to (itself, or the nearest component ancestor). +pub fn owning_component(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) +} + +/// The innermost instance containing `id` (`id` itself when it is an instance). +pub fn owning_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().find(|c| is_instance(doc, *c)) +} + +/// The outermost instance containing `id` (`id` itself when it is a top-level instance). +pub fn root_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c)) +} + +/// The innermost instance `id` is a layer of (never `id` itself). +fn enclosing_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c)) +} + +/// Whether `id` is a layer inside an instance (not the outermost instance itself). +pub fn inside_instance(doc: &Document, id: NodeId) -> bool { + enclosing_instance(doc, id).is_some() +} + +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + +/// The slot property frame `id` stands for (its name and preferred components): a frame bound to +/// a slot property of its component, or an instance's copy of one (followed through its sources). +pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { + let mut cur = id; + for _ in 0..=MAX_DEPTH { + let n = doc.node(cur)?; + if !matches!(n.kind, NodeKind::Frame) { + return None; + } + if let Some(name) = n.prop_refs.get(SLOT) { + let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None }; + return match props.get(name) { + Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())), + _ => None, + }; + } + cur = n.source?; + } + None +} + +/// The name of the slot property frame `id` stands for ([`slot_def`]). +pub fn slot_name(doc: &Document, id: NodeId) -> Option { + slot_def(doc, id).map(|(name, _)| name.to_string()) +} + +/// Whether `id` is a slot (in a main component, or an instance's copy of one). +pub fn is_slot(doc: &Document, id: NodeId) -> bool { + slot_def(doc, id).is_some() +} + +/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's +/// default content. +pub fn slot_edited(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(holds_content) +} + +/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and +/// reordered freely. +pub fn slot_scope(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + for c in chain { + if is_instance(doc, c) { + return None; + } + if is_slot(doc, c) { + return Some(c); + } + } + None +} + +/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an +/// instance, outside its slots. +pub fn structure_locked(doc: &Document, id: NodeId) -> bool { + owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none() +} + +/// For tree walks that look for where layers can go: whether the children of `id` are locked (in +/// an instance, outside its slots), given whether `id` itself is (`locked`). +pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool { + is_instance(doc, id) || (locked && !is_slot(doc, id)) +} + +/// Whether layer `id`'s place (parent and position among its siblings) changed between two +/// versions only where instance structure allows it. +pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool { + let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id))); + let (was, is) = (place(before), place(after)); + was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p))) +} + +/// The slots of a component or instance (its own: not those of instances nested in it, nor of the +/// content put into its slots), with their property names, top-most first. +pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 1_000_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + if let Some(name) = slot_name(doc, c) { + out.push((c, name)); + } + if !is_instance(doc, c) && !holds_content(n) { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} + +/// All instances of `component` in the document. +pub fn instances_of(doc: &Document, component: NodeId) -> Vec { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect() +} + +/// The component set a variant belongs to. +pub fn variant_set(doc: &Document, component: NodeId) -> Option { + doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +/// The layer name a fresh instance of `component` gets: the set's name for variants. +pub fn default_instance_name(doc: &Document, component: NodeId) -> String { + let set = variant_set(doc, component).and_then(|s| doc.node(s)); + set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into()) +} + +/// Default property values of a component (slots hold content, not values: they have none). +pub fn default_props(comp: &Node) -> BTreeMap { + let mut out = BTreeMap::new(); + if let NodeKind::Component { props, .. } = &comp.kind { + for (k, d) in props { + if takes_value(d) { + out.insert(k.clone(), prop_default_value(d)); + } + } + } + out +} + +/// Whether instances store a value for the property (every kind but slots). +pub fn takes_value(d: &PropDef) -> bool { + !matches!(d, PropDef::Slot { .. }) +} + +/// A property definition's default as a JSON value. +pub fn prop_default_value(d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => Value::Bool(*default), + PropDef::Text { default } => Value::String(default.clone()), + PropDef::InstanceSwap { default, .. } => Value::from(default.0), + PropDef::Variant { default, .. } => Value::String(default.clone()), + PropDef::Slot { .. } => Value::Null, + } +} + +#[cfg(test)] +mod rebase_source_tests; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs new file mode 100644 index 000000000..41c1a4246 --- /dev/null +++ b/crates/engine/src/assets/components/diff.rs @@ -0,0 +1,218 @@ +use super::instances::PROP_BINDING; +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). +pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + for (k, v) in &n.bound { + let Some(g) = binding_group(k) else { continue }; + if src.bound.get(k) == Some(v) && !out.contains(&g) { + out.push(g); + } + } + out +} + +/// The override group a variable binding key feeds (`None` for component property and layout +/// grid bindings, which follow other rules). +pub(super) fn binding_group(key: &str) -> Option<&'static str> { + use crate::cmd::lists::{BoundList, list_binding}; + if key.starts_with(PROP_BINDING) { + return None; + } + match list_binding(key).map(|(l, _, _)| l) { + Some(BoundList::Fills) => Some("fills"), + Some(BoundList::Strokes) => Some("strokes"), + Some(BoundList::Effects) => Some("effects"), + Some(BoundList::Grids) => None, + None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), + } +} + +/// The variable bindings of `n` that feed group `g`. +pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { + n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) +} + +/// Property groups that differ between two versions of a node. +pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + if a.fills != b.fills { + out.push("fills"); + } + if a.strokes != b.strokes || a.stroke != b.stroke { + out.push("strokes"); + } + if a.effects != b.effects { + out.push("effects"); + } + if a.opacity != b.opacity || a.blend != b.blend { + out.push("opacity"); + } + if a.visible != b.visible { + out.push("visible"); + } + if a.name != b.name { + out.push("name"); + } + if a.corners != b.corners { + out.push("corners"); + } + if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { + out.push("size"); + } + if resizing(a) != resizing(b) { + out.push("resizing"); + } + if a.transform != b.transform { + out.push("position"); + } + if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { + if x.characters != y.characters { + out.push("characters"); + } + if text_style_differs(x, y) { + out.push("textStyle"); + } + } + if let (Some(x), Some(y)) = (&a.layout, &b.layout) { + if x.padding != y.padding { + out.push("padding"); + } + if x.gap != y.gap || x.cross_gap != y.cross_gap { + out.push("gap"); + } + if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { + out.push("alignment"); + } + } + if a.grids != b.grids || a.styles.grid != b.styles.grid { + out.push("grids"); + } + if a.exports != b.exports { + out.push("exports"); + } + if a.interactions != b.interactions { + out.push("interactions"); + } + if a.var_modes != b.var_modes { + out.push("modes"); + } + if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { + if x != y { + out.push("component"); + } + if px != py { + out.push("props"); + } + } + out +} + +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + +/// Whether two texts differ in anything but their characters (the `textStyle` group). +fn text_style_differs(x: &TextData, y: &TextData) -> bool { + x.style != y.style + || x.runs != y.runs + || x.auto_resize != y.auto_resize + || x.align_h != y.align_h + || x.align_v != y.align_v + || x.paragraph_spacing != y.paragraph_spacing + || x.paragraph_indent != y.paragraph_indent + || x.list != y.list + || x.paragraphs != y.paragraphs + || x.list_spacing != y.list_spacing + || x.hanging_punctuation != y.hanging_punctuation + || x.hanging_list != y.hanging_list + || x.max_lines != y.max_lines + || x.features != y.features +} + +/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was +/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. +pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { + for a in doc.ancestors(id) { + let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; + if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { + return true; + } + } + let Some(n) = doc.node(id) else { return true }; + if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { + return true; + } + if group == "size" + && let NodeKind::Text(t) = &n.kind + { + let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); + return match t.auto_resize { + TextAutoResize::WidthAndHeight => true, + TextAutoResize::Height => !wchanged, + _ => false, + }; + } + false +} + +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs new file mode 100644 index 000000000..0424beff6 --- /dev/null +++ b/crates/engine/src/assets/components/guard.rs @@ -0,0 +1,138 @@ +use super::diff::geometry_derived; +use super::rebase::rebase_instance; +use super::variants::instance_issues; +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 +/// alone, linked to their own mains. +pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) { + if let Some(n) = doc.node_mut(slot) { + n.overrides.insert(SLOT.into()); + } + for nested in unlink_layers(doc, slot, None) { + rebase_instance(doc, nested); + } +} + +/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the +/// content of a slot): plain layers forget the main layer they mirrored and their property +/// bindings. The instances among them are returned, to re-link to their own mains (with +/// [`rebase_instance`]) once the caller is done. +pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { + let picked: Vec<(NodeId, bool)> = + doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); + let mut nested = Vec::new(); + for (d, instance) in picked { + let Some(n) = doc.node_mut(d) else { continue }; + n.prop_refs.clear(); + if instance { + nested.push(d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nested +} + +/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it +/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). +pub(super) fn relink_copy(doc: &mut Document, id: NodeId) { + let Some(n) = doc.node(id) else { return }; + match &n.kind { + NodeKind::Instance { component, .. } => { + let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); + if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { + rebase_instance(doc, id); + } + } + _ => { + if (n.source.is_some() || !n.overrides.is_empty()) + && owning_instance(doc, id).is_none() + && let Some(m) = doc.node_mut(id) + { + m.source = None; + m.overrides.clear(); + } + } + } +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside +/// its slots. +pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { + for &id in after.dirty() { + let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; + // Library copies change only through library updates (and are read-only otherwise). + if n.children == b.children || after.is_remote(id) { + continue; + } + if structure_locked(before, id) && structure_locked(after, id) { + let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); + return Err(format!( + "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" + )); + } + } + Ok(()) +} + +/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled +/// back. +pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { + match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { + Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), + None => Ok(()), + } +} + +/// What about layer `id` an edit from `before` to `after` changed that only its main can: the +/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). +pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { + let (n, b) = (after.node(id)?, before.node(id)?); + if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { + return None; + } + let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); + let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); + // Auto layout settings other than the overridable ones. + let settings = |x: &Node| { + x.layout.clone().map(|mut l| { + (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l.content_align = ContentAlign::Auto; + l + }) + }; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; + if mode(n) != mode(b) && mode(ma) == mode(mb) { + return Some("The auto layout direction"); + } + if settings(n) != settings(b) && settings(ma) == settings(mb) { + return Some("This auto layout setting"); + } + if !inside_instance(after, id) || !inside_instance(before, id) { + return None; + } + if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { + return Some("A layer's position"); + } + if n.constraints != b.constraints && ma.constraints == mb.constraints { + return Some("A layer's constraints"); + } + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); + } + None +} diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs new file mode 100644 index 000000000..ac7ff4ec0 --- /dev/null +++ b/crates/engine/src/assets/components/instances.rs @@ -0,0 +1,345 @@ +use super::diff::{binding_group, bindings_of}; +use super::sync::{insert_linked, sync_instance_depth}; +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 { + create_instance_at(doc, component, parent, x, y, None) +} + +/// [`create_instance`] at child `index` of `parent` (None: on top). +pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result { + let comp = doc.get(component)?.clone(); + if !matches!(comp.kind, NodeKind::Component { .. }) { + return Err(DocError::Invalid(format!("{component} is not a component"))); + } + if doc.is_ancestor(component, parent) { + return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + } + let id = doc.alloc_id(); + let mut root = comp.clone(); + root.id = id; + root.name = default_instance_name(doc, component); + root.kind = NodeKind::Instance { component, props: default_props(&comp) }; + root.children.clear(); + root.parent = None; + root.source = Some(component); + root.overrides.clear(); + root.transform = Affine::translate((x, y)); + root.prop_refs.clear(); + root.annotations.clear(); + root.code_links.clear(); + // The main's dev resources show on its instances without being copied. + root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); + // The main's interactions play on its instances in prototypes; the instance's own start empty. + root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); + doc.insert(root, parent, index)?; + sync_instance(doc, id); + Ok(id) +} + +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + +/// Which part of the carried overrides to apply. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Carry { + /// Paint, text, visibility, … (everything but geometry). + Values, + /// Values plus size (instance roots: their size decides how children follow constraints). + Root, + /// Size and position (children, after constraints ran). + Geometry, +} + +/// Copy the overridden property groups of `from` onto `to`. +pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { + for g in &from.overrides { + let geo = matches!(g.as_str(), "size" | "position"); + match what { + Carry::Values if geo => continue, + Carry::Root if g == "position" => continue, + Carry::Geometry if !geo => continue, + _ => {} + } + copy_group(from, to, g); + } + if what != Carry::Geometry { + to.overrides = from.overrides.clone(); + } +} + +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + +/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. +pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { + if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { + to.bound.retain(|k, _| binding_group(k) != Some(g)); + to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); + } + match g { + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } + "strokes" => { + to.strokes = from.strokes.clone(); + to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); + } + "opacity" => { + to.opacity = from.opacity; + to.blend = from.blend; + } + "visible" => to.visible = from.visible, + "name" => to.name = from.name.clone(), + "corners" => to.corners = from.corners, + "characters" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.characters = a.characters.clone(); + b.runs = a.runs.clone(); + b.paragraphs = a.paragraphs.clone(); + } + } + "textStyle" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.style = a.style.clone(); + b.runs = a.runs.clone(); + b.auto_resize = a.auto_resize; + b.align_h = a.align_h; + b.align_v = a.align_v; + b.paragraph_spacing = a.paragraph_spacing; + b.paragraph_indent = a.paragraph_indent; + b.list = a.list; + b.paragraphs = a.paragraphs.clone(); + b.list_spacing = a.list_spacing; + b.hanging_punctuation = a.hanging_punctuation; + b.hanging_list = a.hanging_list; + b.max_lines = a.max_lines; + b.features = a.features.clone(); + } + to.styles.text = from.styles.text.clone(); + } + "padding" | "gap" | "alignment" => { + if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { + match g { + "padding" => b.padding = a.padding, + "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), + _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), + } + } + } + "grids" => { + to.grids = from.grids.clone(); + to.styles.grid = from.styles.grid.clone(); + } + "exports" => to.exports = from.exports.clone(), + "interactions" => to.interactions = from.interactions.clone(), + "size" => { + to.width = from.width; + to.height = from.height; + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } + } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } + "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), + "component" => { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { + *b = *a; + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); + } + carry_prop_bindings(from, to); + } + _ => {} + } +} + +/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With +/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames +/// don't break matching), and the content of slots holding the instance's own is left out. +pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![(root, String::new())]; + while let Some((id, prefix)) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + // A nested instance linked only to its own component so far (an imported one) mirrors no + // layer yet: it goes by its own name, like the main's layer it will be matched with. + let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); + let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); + let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); + let k = seen.entry(name).or_insert(0); + let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; + *k += 1; + let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; + out.insert(*c, path.clone()); + if out.len() > 1_000_000 { + return out; + } + if !(src_names && holds_content(cn)) { + stack.push((*c, path)); + } + } + } + out +} + +/// The binding key prefix of an instance's component property value +/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a +/// string variable. +pub const PROP_BINDING: &str = "componentProperties/"; + +/// Give `to` the component property bindings of instance `from` (they go with its property +/// values, which an instance keeps across rebuilds). +pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) { + to.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone()))); +} + +/// The override group a property-bound field belongs to. +pub(super) fn field_group(field: &str) -> &'static str { + match field { + "visible" => "visible", + "characters" => "characters", + "mainComponent" => "component", + _ => "", + } +} + +/// Apply one component-property value to a layer. +pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { + match field { + "visible" => { + if let Some(b) = val.as_bool() { + n.visible = b; + } + } + "characters" => { + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) + && t.characters != sv + { + let len = t.char_count(); + t.replace_range(0, len, &sv); + } + } + "mainComponent" => { + if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) + && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) + { + *c = v; + } + } + _ => {} + } +} + +/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its +/// slots. An instance whose main component was deleted keeps its layers as they are. +pub fn sync_instance(doc: &mut Document, inst: NodeId) { + // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). + let old: Vec<(std::sync::Arc, bool)> = + doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); + let kept = slot_content(doc, inst); + sync_instance_depth(doc, inst, 0, BTreeMap::new()); + restore_slot_content(doc, kept); + for (a, was_dirty) in old { + doc.keep_if_unchanged(a, was_dirty); + } +} + +/// The slots in instance `inst` (nested instances included) holding the instance's own content: +/// each with its content (the subtrees of its children, pre-order). +fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); + while let Some(c) = stack.pop() { + let Some(n) = doc.node(c) else { continue }; + if holds_content(n) { + let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); + out.push((c, nodes)); + } else { + stack.extend(n.children.iter().copied()); + } + } + out +} + +/// Put slot content back after a rebuild, into the slots that still hold the instance's own +/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints +/// and geometry overrides ran before, so the content keeps its place. +fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { + for (slot, nodes) in kept { + let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); + if !keep { + continue; + } + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + for n in nodes { + // Pre-order: every parent is back before its children. + let _ = insert_linked(doc, n); + } + // Never leave links to a layer that couldn't come back. + for id in ids.into_iter().chain([slot]) { + let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); + if dangling { + let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); + if let Some(n) = doc.node_mut(id) { + n.children = live; + } + } + } + } +} diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs new file mode 100644 index 000000000..8ae01bc14 --- /dev/null +++ b/crates/engine/src/assets/components/patch.rs @@ -0,0 +1,548 @@ +use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups}; +use super::guard::{own_slot_content, relink_copy}; +use super::instances::{copy_group, field_group, sync_instance}; +use super::sync::insert_linked; +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. +pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); + adopt_slot_content(doc, before, changed); + let mut resync: BTreeSet = BTreeSet::new(); + 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(); + let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new(); + // Main layers that only gained new layers or lost some: their copies take the same change. + let mut restructured: Vec = Vec::new(); + let mut covered: BTreeSet = BTreeSet::new(); + for &id in changed { + if let (Some(b), Some(n)) = (before.node(id), doc.node(id)) + && b.children != n.children + && owning_instance(doc, id).is_none() + && owning_component(doc, id).is_some() + && let Some(layers) = restructurable(doc, before, b, n) + { + restructured.push(id); + covered.extend(layers); + } + } + for &id in changed { + if covered.contains(&id) || restructured.contains(&id) { + continue; + } + let Some(c) = owning_component(doc, id) else { continue }; + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { + comps.insert(c); + continue; + }; + if b == n { + continue; + } + match patchable(doc, b, n) { + Some(groups) => patches.push((id, groups)), + None => { + comps.insert(c); + } + } + } + for &id in touched { + if !doc.contains(id) + && !covered.contains(&id) + && let Some(c) = owning_component(before, id) + && doc.contains(c) + { + comps.insert(c); + } + } + for r in resync { + if doc.contains(r) { + sync_instance(doc, r); + if let Some(c) = owning_component(doc, r) { + comps.insert(c); + } + } + } + // A copy the patch can't follow exactly rebuilds instead (as does any other change). + let patched = comps.is_empty() + && !full_rebuild() + && (restructured.is_empty() || patch_structure(doc, before, &restructured)) + && (patches.is_empty() || patch_copies(doc, &patches)); + #[cfg(test)] + if patched && (!restructured.is_empty() || !patches.is_empty()) { + PATCHED.with(|c| c.set(c.get() + 1)); + } + if !patched { + comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id))); + } + // 3. Instances of changed components follow; an instance inside another component makes that + // one change too. + 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). + pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: edits carried to the copies without rebuilding instances. + pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn full_rebuild() -> bool { + FULL_REBUILD.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn full_rebuild() -> bool { + false +} + +/// The property groups of a main-component layer edited from `b` to `n` when the edit changed +/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without +/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. +fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { + const VALUES: [&str; 17] = [ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "position", + ]; + if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { + return None; + } + let groups = diff_groups(b, n); + if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) { + return None; + } + // Exactly those groups (and the layer's own overrides) changed. + let mut probe = n.clone(); + for g in &groups { + copy_group(b, &mut probe, g); + } + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + // Values a component property sets (on this layer or a main it mirrors) come from instances. + let mut cur = Some(n); + for _ in 0..=MAX_DEPTH { + let Some(x) = cur else { break }; + if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) { + return None; + } + cur = x.source.and_then(|s| doc.node(s)); + } + Some(groups) +} + +/// Copies by the layer they mirror; nested instances swapped to a main also follow it. +fn copies_index(doc: &Document) -> BTreeMap> { + let mut copies: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + let Some(s) = n.source else { continue }; + copies.entry(s).or_default().push(n.id); + if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind)) + && component != other + { + copies.entry(*component).or_default().push(n.id); + } + } + copies +} + +/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances, +/// components or property bindings in them) or lost layers that were deleted: those layers, which +/// [`patch_structure`] carries to the copies. `None` for anything else. +fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> { + let mut probe = n.clone(); + probe.children = b.children.clone(); + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + let mut layers = Vec::new(); + for c in n.children.iter().filter(|c| !b.children.contains(c)) { + for d in doc.subtree(*c) { + let x = doc.node(d)?; + let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); + if before.contains(d) || !plain || !x.prop_refs.is_empty() { + return None; + } + layers.push(d); + } + } + for c in b.children.iter().filter(|c| !n.children.contains(c)) { + if doc.contains(*c) { + return None; + } + layers.extend(before.subtree(*c)); + } + Some(layers) +} + +/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy, +/// through copies of copies (slots holding an instance's own content keep it). Returns false +/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool { + let copies = copies_index(doc); + let mut work: Vec = seeds.to_vec(); + let mut steps = 0usize; + while let Some(p) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c).cloned() else { continue }; + if holds_content(&cn) { + continue; + } + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // The copy's layers by the layer of `p` they mirror. + let mut mirrors: BTreeMap = BTreeMap::new(); + for k in &cn.children { + let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false }; + if mirrors.insert(src, *k).is_some() { + return false; + } + } + let mut order = Vec::with_capacity(pn.children.len()); + for pc in &pn.children { + if let Some(k) = mirrors.remove(pc) { + order.push(k); + continue; + } + // A layer the copy lacks must be new in this edit, placed as in the main. + let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute); + if before.contains(*pc) || !child_follows_main(doc, c, absolute) { + return false; + } + match copy_layers(doc, *pc, c) { + Some(k) => order.push(k), + None => return false, + } + } + // Copies of layers the main lost. + for (src, k) in mirrors { + if doc.contains(src) || doc.remove(k).is_err() { + return false; + } + } + if doc.node(c).is_some_and(|x| x.children != order) + && let Some(m) = doc.node_mut(c) + { + m.children = order; + } + work.push(c); + } + } + true +} + +/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's +/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]). +fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { + let Some(p) = doc.node(parent) else { return false }; + let constrains = p.layout.is_none() || absolute; + let resized = sized_own(p) + || match &p.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) + } + _ => false, + }; + !(constrains && resized) && follows_main_geometry(doc, parent) +} + +/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the +/// new copy (the caller places it among `parent`'s layers). +fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option { + let sub = doc.subtree(src); + let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect(); + for d in &sub { + let mut n = doc.node(*d)?.clone(); + n.id = *map.get(d)?; + n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) }; + n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); + n.source = Some(*d); + n.overrides.clear(); + n.prop_refs.clear(); + n.annotations.clear(); + n.measurements.clear(); + insert_linked(doc, n).ok()?; + } + map.get(&src).copied() +} + +/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through +/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial +/// patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool { + let copies = copies_index(doc); + let text = |g: &str| matches!(g, "characters" | "textStyle"); + let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec(); + let mut steps = 0usize; + while let Some((p, groups)) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + // A swapped nested instance shows its own main, not the layer it stands in for. + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // An instance keeps its own visibility, place, export settings and interactions; its + // name follows the main's unless renamed; a resized one lays its layers out against + // the main's size. + let root = matches!(pn.kind, NodeKind::Component { .. }); + if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { + return false; + } + let gs: Vec<&'static str> = groups + .iter() + .copied() + .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .collect(); + if gs.is_empty() { + continue; + } + let geometry = gs.iter().any(|g| matches!(*g, "size" | "position")); + if geometry && !follows_main_geometry(doc, c) { + return false; + } + // Text content and style share their runs: a copy overriding one keeps its runs when the + // other follows the main (as a rebuild carries them). + let runs = match &cn.kind { + NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()), + _ => None, + }; + if let Some(m) = doc.node_mut(c) { + for g in &gs { + copy_group(&pn, m, g); + } + if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) { + t.runs = r; + } + } + if (geometry || gs.iter().any(|g| text(g))) + && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size) + && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6) + { + figcraft_layout::set_size(doc, c, w, h); + } + work.push((c, gs)); + } + } + true +} + +/// Whether a copy's size and place are its main layer's: no layer around it inside its instances +/// applies constraints on the way down to it (a resized instance or frame without auto layout, or +/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest. +fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { + let chain = scope(doc, id); + chain.windows(2).all(|w| { + let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; + let constrains = a.layout.is_none() || below.child_layout.absolute; + if sized_own(a) && constrains { + return false; + } + match &a.kind { + NodeKind::Instance { component, .. } => { + !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9) + } + _ => true, + } + }) +} + +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + +/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on +/// outwards), each outermost instance once, inner components first. +fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { + if comps.is_empty() { + return; + } + // Outermost instances by the components their layers instantiate. + let mut roots_of: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + if let NodeKind::Instance { component, .. } = &n.kind { + roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id)); + } + } + // Affected components, with how deep their dependencies go (an instance inside component X of + // component Y: X follows Y). + let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect(); + let mut outer: BTreeMap> = BTreeMap::new(); + let mut frontier: Vec = comps.iter().copied().collect(); + let mut rounds = 0; + while !frontier.is_empty() && rounds < 64 { + rounds += 1; + let mut next = Vec::new(); + for c in frontier { + let lc = level.get(&c).copied().unwrap_or(0); + for r in roots_of.get(&c).into_iter().flatten() { + let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r)); + if let Some(x) = o + && x != c + { + let lx = level.entry(x).or_insert(0); + if *lx < lc + 1 { + *lx = lc + 1; + next.push(x); + } + } + } + } + frontier = next; + } + // Instances inside components by level, then the rest (each once). + let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect(); + order.sort(); + for (_, r) in order { + if doc.contains(r) { + sync_instance(doc, r); + } + } +} diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs new file mode 100644 index 000000000..ea9cb6f3e --- /dev/null +++ b/crates/engine/src/assets/components/rebase.rs @@ -0,0 +1,249 @@ +use super::diff::{diff_groups, variable_groups}; +use super::instances::{field_group, path_map}; +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> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + pub(super) fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + +/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing +/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): +/// the look is kept exactly. Values a component property sets aren't overrides. +pub fn rebase_instance(doc: &mut Document, inst: NodeId) { + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; + let mine = path_map(doc, inst, true); + let theirs: BTreeMap = + if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; + let value_groups = |a: &Node, b: &Node| -> Vec { + diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() + }; + let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); + for (id, path) in &mine { + let Some(n) = doc.node(*id) else { continue }; + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; + } + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); + let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); + if let Some(m) = source.and_then(|source| doc.node(source)) { + let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + bound.extend(variable_groups(n, m)); + overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); + if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { + overrides.insert("component".into()); + } + } + updates.push((*id, source, overrides)); + } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } + if let Some(n) = doc.node_mut(inst) { + n.source = root_base; + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); + n.overrides.extend(root_groups); + } +} diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/assets/components/rebase_source_tests.rs similarity index 100% rename from crates/engine/src/components_rebase_source_tests.rs rename to crates/engine/src/assets/components/rebase_source_tests.rs diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs new file mode 100644 index 000000000..e81bf4379 --- /dev/null +++ b/crates/engine/src/assets/components/sync.rs @@ -0,0 +1,505 @@ +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::{ + 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. +pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { + if depth > MAX_DEPTH { + return; + } + let Some(mut root) = doc.node(inst).cloned() else { return }; + let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; + let component = *component; + let Some(comp) = doc.node(component).cloned() else { return }; + if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { + // A missing main, or an instance inside its own main (would recurse forever). + return; + } + 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, 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 + // parent keeps its id and overrides). + let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); + let mut map: BTreeMap = BTreeMap::new(); + map.insert(component, inst); + let mut used: BTreeSet = BTreeSet::new(); + // Slots holding the instance's own content (new ids), and main layers not copied: the + // default content of those slots. + let mut edited: BTreeSet = BTreeSet::new(); + let mut skipped: BTreeSet = BTreeSet::new(); + let mut fresh_ids = Vec::new(); + for s in &src { + let Some(sn) = doc.node(*s) else { continue }; + if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { + skipped.insert(*s); + continue; + } + let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); + let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); + if found.is_none() { + let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); + let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false)); + found = mains.get(s).and_then(|p| paths.get(p)).copied(); + } + let nid = match found { + Some(o) if used.insert(o) => o, + _ => { + fresh_ids.push(*s); + continue; + } + }; + if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { + old.insert(nid, std::sync::Arc::new(m)); + } + if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { + edited.insert(nid); + } + map.insert(*s, nid); + } + for c in root.children.clone() { + let _ = doc.remove(c); + } + for s in fresh_ids { + let nid = doc.alloc_id(); + map.insert(s, nid); + } + // Build the copies (pre-order: parents first). + let mut enclosing: BTreeMap> = BTreeMap::new(); + enclosing.insert(inst, vec![inst]); + let mut inst_props: BTreeMap> = BTreeMap::new(); + inst_props.insert(inst, eff_props); + let mut nested = Vec::new(); + let mut new_nodes = Vec::new(); + // Text layers showing other text or style than their main layer (to re-measure). + let mut remeasure = Vec::new(); + for s in &src { + let Some(orig) = doc.node(*s) else { continue }; + let Some(&nid) = map.get(s) else { continue }; + let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; + let mut n = orig.clone(); + n.id = nid; + n.parent = Some(parent); + n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect(); + n.source = Some(*s); + n.overrides.clear(); + n.prop_refs.clear(); + let prev = old.get(&nid); + // Annotations belong to the layer they were added to, not to its copies. + n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); + if let Some(o) = prev { + carry_overrides(o, &mut n, Carry::Values); + keep_laid_out(doc, orig, o, &mut n); + } + if !edited.contains(&nid) { + // No longer a slot in the main: it mirrors the main again. + n.overrides.remove(SLOT); + } + // 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(); + 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; + e.push(nid); + enclosing.insert(nid, e); + if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { + nested.push(nid); + } + } else { + enclosing.insert(nid, enc); + } + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) + && a != b + { + remeasure.push(nid); + } + new_nodes.push(n); + } + for n in new_nodes { + let (id, parent) = (n.id, n.parent); + if insert_linked(doc, n).is_err() + && let Some(p) = parent.and_then(|p| doc.node_mut(p)) + { + 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(); + fresh.id = inst; + fresh.parent = root.parent; + fresh.children = order; + fresh.transform = root.transform; + fresh.kind = root.kind.clone(); + 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) }; + fresh.constraints = root.constraints; + fresh.child_layout = root.child_layout.clone(); + fresh.interactions = root.interactions.clone(); + fresh.exports = root.exports.clone(); + fresh.prop_refs = root.prop_refs.clone(); + fresh.plugin_data = root.plugin_data.clone(); + fresh.annotations = root.annotations.clone(); + // Code links belong to the main component; instances read them from it. + fresh.code_links.clear(); + // An instance's own dev resources (its main's are listed with them, not copied). + fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); + fresh.locked = root.locked; + fresh.visible = root.visible; + fresh.var_modes = root.var_modes.clone(); + fresh.is_mask = root.is_mask; + fresh.exposed = root.exposed; + fresh.guides = root.guides.clone(); + fresh.dev_status = root.dev_status; + fresh.dev_changed = root.dev_changed; + fresh.dev_note = root.dev_note.clone(); + fresh.constrain_proportions = root.constrain_proportions; + // How the instance combines in a boolean group it sits in is its own. + fresh.bool_op = root.bool_op; + // A swapped nested instance keeps the name its layer was given in the outer main. + let renamed = nested_copy + && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { + NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c), + _ => false, + }); + 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); + 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); + fresh +} + +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + +/// Whether main components `a` and `b` are two variants of one set. +fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { + a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) +} + +/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched +/// variants): `o` without the overrides of properties the two variants' layers differ in (`None` +/// when it keeps them all). Text layers keep every override. +fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option { + let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?; + if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) { + return None; + } + if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) { + return None; + } + let differs = diff_groups(from, doc.node(s)?); + let mut m = o.clone(); + m.overrides.retain(|g| !differs.contains(&g.as_str())); + (m.overrides.len() != o.overrides.len()).then_some(m) +} + +/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of +/// its slots): what swapping it from the Assets panel keeps. +pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) { + for d in old_layers(doc, inst).into_iter().chain([inst]) { + let Some(n) = doc.node(d) else { continue }; + let text = matches!(n.kind, NodeKind::Text(_)); + let keep = |g: &String| text || g == SLOT || (d == inst && g == "size"); + if !n.overrides.iter().all(keep) + && let Some(m) = doc.node_mut(d) + { + m.overrides.retain(keep); + } + } +} + +/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old +/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually +/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever +/// something that sizes or places layers did change. +fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) { + if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") { + return; + } + let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flows { + // The main's rotation and skew, the old place. + let [a, b, c, d, _, _] = n.transform.as_coeffs(); + let [.., e, f] = o.transform.as_coeffs(); + n.transform = Affine::new([a, b, c, d, e, f]); + } + // Only layers whose shape is their box (a vector's points scale with it). + let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. }); + let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows)); + if derived(n.child_layout.sizing_h) { + n.width = o.width; + } + if derived(n.child_layout.sizing_v) { + n.height = o.height; + } +} + +/// The layers of instance `inst` a rebuild matches against (every layer below it but the content +/// of slots holding its own), ascending. +fn old_layers(doc: &Document, inst: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![inst]; + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + for c in &n.children { + out.push(*c); + if out.len() > 1_000_000 { + break; + } + if doc.node(*c).is_some_and(|cn| !holds_content(cn)) { + stack.push(*c); + } + } + } + out.sort(); + out.dedup(); + out +} + +/// Insert a node whose parent/children links are already set (used when rebuilding instances). +pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { + let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; + let id = n.id; + let mut n = n; + let kids = std::mem::take(&mut n.children); + doc.insert(n, parent, None)?; + if let Some(m) = doc.node_mut(id) { + m.children = kids; + } + // The parent may already list us (its children were copied with ids): keep the first. + if let Some(p) = doc.node_mut(parent) { + let mut seen = BTreeSet::new(); + p.children.retain(|c| seen.insert(*c)); + } + Ok(()) +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/assets/components/tests.rs similarity index 97% rename from crates/engine/src/components_tests.rs rename to crates/engine/src/assets/components/tests.rs index 30254651a..82aabc63f 100644 --- a/crates/engine/src/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 new file mode 100644 index 000000000..bc0d25b7f --- /dev/null +++ b/crates/engine/src/assets/components/variants.rs @@ -0,0 +1,147 @@ +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). +pub fn default_variant(doc: &Document, set: NodeId) -> Option { + let corner = |c: &NodeId| { + doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) + }; + let kids = &doc.node(set)?.children; + kids.iter() + .copied() + .filter_map(|c| corner(&c).map(|k| (k, c))) + .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(_, c)| c) +} + +/// Variants of a set sharing one combination of property values (each group two or more, in +/// layer order): a conflict to resolve by giving each a unique combination. +pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { + let mut by: BTreeMap, Vec> = BTreeMap::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { + by.entry(variant_props(&n.name)).or_default().push(c); + } + } + by.into_values().filter(|v| v.len() > 1).collect() +} + +/// Parse a variant name "Size=Large, State=Hover" into pairs. +pub fn variant_props(name: &str) -> BTreeMap { + name.split(',') + .filter_map(|kv| { + let (k, v) = kv.split_once('=')?; + Some((k.trim().to_string(), v.trim().to_string())) + }) + .collect() +} + +/// Format variant pairs as a layer name, in the given property order (others after, sorted). +pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { + let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); + keys.extend(pairs.keys().filter(|k| !order.contains(k))); + keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") +} + +/// Variant properties of a set: property → values, in first-seen order. +pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + for kv in cn.name.split(',') { + let Some((k, v)) = kv.split_once('=') else { continue }; + let (k, v) = (k.trim().to_string(), v.trim().to_string()); + match out.iter_mut().find(|(p, _)| *p == k) { + Some((_, vals)) => { + if !vals.contains(&v) { + vals.push(v); + } + } + None => out.push((k, vec![v])), + } + } + } + out +} + +/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. +pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { + let s = doc.node(set)?; + let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); + if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { + return Some(exact); + } + s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) +} + +/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each +/// layer's `source` is the main layer at the same place, swapped nested instances mirror their +/// own main, slots holding the instance's own content aren't compared. Returns the problems found. +pub fn mirror_issues(doc: &Document) -> Vec { + doc.nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) + .flat_map(|n| instance_issues(doc, n.id)) + .collect() +} + +/// [`mirror_issues`] for one instance. +pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec { + fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH || holds_content(inst) { + return; + } + if inst.children.len() != main.children.len() { + out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); + return; + } + for (a, b) in inst.children.iter().zip(&main.children) { + let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; + if an.source != Some(bn.id) { + out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); + continue; + } + match (&an.kind, &bn.kind) { + (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { + if let Some(m) = doc.node(*x) { + walk(doc, an, m, depth + 1, out); + } + } + _ => walk(doc, an, bn, depth + 1, out), + } + } + } + let mut out = Vec::new(); + if let Some(n) = doc.node(id) + && let NodeKind::Instance { component, .. } = &n.kind + && let Some(m) = doc.node(*component) + { + walk(doc, n, m, 0, &mut out); + } + out +} + +/// The nested instance copies inside instance `id` whose main-component counterpart is exposed +/// (top-most first; an exposed instance's own exposed nested instances follow it). +pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 100_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + // A hidden exposed instance hides its properties too. + let exposed = + n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + if exposed { + out.push(c); + } + // Only exposed nested instances pass their own exposed instances up. + if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/assets/library.rs similarity index 99% rename from crates/engine/src/library.rs rename to crates/engine/src/assets/library.rs index 40d6b3cad..24699ef2c 100644 --- a/crates/engine/src/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; @@ -1331,5 +1335,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator 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/library_server.rs b/crates/engine/src/assets/library_server.rs similarity index 99% rename from crates/engine/src/library_server.rs rename to crates/engine/src/assets/library_server.rs index 10852b5fd..b9dbce478 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -212,5 +212,4 @@ impl Session { } #[cfg(test)] -#[path = "library_server_tests.rs"] mod tests; diff --git a/crates/engine/src/library_server_tests.rs b/crates/engine/src/assets/library_server/tests.rs similarity index 100% rename from crates/engine/src/library_server_tests.rs rename to crates/engine/src/assets/library_server/tests.rs diff --git a/crates/engine/src/library_swap.rs b/crates/engine/src/assets/library_swap.rs similarity index 93% rename from crates/engine/src/library_swap.rs rename to crates/engine/src/assets/library_swap.rs index de709b104..1dfc1691e 100644 --- a/crates/engine/src/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(); @@ -245,5 +257,4 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re } #[cfg(test)] -#[path = "library_swap_tests.rs"] mod tests; diff --git a/crates/engine/src/library_swap_tests.rs b/crates/engine/src/assets/library_swap/tests.rs similarity index 100% rename from crates/engine/src/library_swap_tests.rs rename to crates/engine/src/assets/library_swap/tests.rs diff --git a/crates/engine/src/playground.rs b/crates/engine/src/assets/playground.rs similarity index 98% rename from crates/engine/src/playground.rs rename to crates/engine/src/assets/playground.rs index 90a627b09..5c05c3b94 100644 --- a/crates/engine/src/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/style_vars.rs b/crates/engine/src/assets/style_vars.rs similarity index 97% rename from crates/engine/src/style_vars.rs rename to crates/engine/src/assets/style_vars.rs index b2e5c182f..7fb30fd06 100644 --- a/crates/engine/src/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/token_roles.rs b/crates/engine/src/assets/token_roles.rs similarity index 100% rename from crates/engine/src/token_roles.rs rename to crates/engine/src/assets/token_roles.rs diff --git a/crates/engine/src/automation.rs b/crates/engine/src/automation.rs new file mode 100644 index 000000000..acf11e5c7 --- /dev/null +++ b/crates/engine/src/automation.rs @@ -0,0 +1,5 @@ +pub mod batch; +pub mod doc_urls; +pub mod file_access; +pub mod inspect; +pub mod params; diff --git a/crates/engine/src/batch.rs b/crates/engine/src/automation/batch.rs similarity index 100% rename from crates/engine/src/batch.rs rename to crates/engine/src/automation/batch.rs diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/automation/doc_urls.rs similarity index 100% rename from crates/engine/src/doc_urls.rs rename to crates/engine/src/automation/doc_urls.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/automation/file_access.rs similarity index 99% rename from crates/engine/src/file_access.rs rename to crates/engine/src/automation/file_access.rs index 7a8ab8c92..f741e21c9 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -306,5 +306,4 @@ pub fn create_dir_all(path: impl AsRef) -> Result<()> { } #[cfg(test)] -#[path = "file_access_tests.rs"] mod tests; diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/automation/file_access/tests.rs similarity index 100% rename from crates/engine/src/file_access_tests.rs rename to crates/engine/src/automation/file_access/tests.rs diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs new file mode 100644 index 000000000..886d2d850 --- /dev/null +++ b/crates/engine/src/automation/inspect.rs @@ -0,0 +1,388 @@ +//! Document inspection as JSON, so agents can verify their work without screenshots. + +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration}; +use serde_json::{Value, json}; + +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 mut v = json!({ + "id": n.id, + "name": n.name, + "type": n.kind.type_name(), + }); + let Some(o) = v.as_object_mut() else { return v }; + if !n.is_page() { + 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) => 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})); + } + NodeKind::Polygon { count } => { + o.insert("pointCount".into(), json!(count)); + } + NodeKind::Star { count, inner_ratio } => { + o.insert("pointCount".into(), json!(count)); + o.insert("innerRadius".into(), json!(inner_ratio)); + } + NodeKind::BooleanOp { op } => { + o.insert("booleanOperation".into(), json!(op)); + } + NodeKind::Instance { component, props } => { + o.insert("mainComponent".into(), json!(component)); + o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone()))); + if !props.is_empty() { + o.insert("componentProperties".into(), json!(props)); + } + } + NodeKind::Component { description, props } => { + if !description.is_empty() { + o.insert("description".into(), json!(description)); + } + if !props.is_empty() { + o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null)); + } + } + NodeKind::Vector { network } => { + o.insert("vertices".into(), json!(network.vertices.len())); + o.insert("segments".into(), json!(network.segments.len())); + } + NodeKind::Page { background, flows, .. } => { + o.insert("background".into(), json!(background.to_css_hex())); + if !flows.is_empty() { + o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null)); + } + } + _ => {} + } +} + +fn round(v: f64) -> f64 { + figcraft_geom::round_to(v, 2) +} + +fn paints(p: &[figcraft_color::Paint]) -> Value { + Value::Array( + p.iter() + .map(|x| match &x.kind { + figcraft_color::PaintKind::Solid { color } => { + let mut v = json!({"type": "solid", "color": color.to_css_hex()}); + if x.opacity < 1.0 { + v["opacity"] = json!(x.opacity); + } + if x.blend != figcraft_color::BlendMode::Normal { + v["blend"] = json!(x.blend); + } + if !x.visible { + v["visible"] = json!(false); + } + v + } + _ => serde_json::to_value(x).unwrap_or(Value::Null), + }) + .collect(), + ) +} + +/// The whole document (current page in depth, other pages summarised). +pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { + let Ok(ds) = s.ds() else { return json!({"documents": 0}) }; + let doc = &ds.doc; + let pages: Vec = + doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); + json!({ + "name": doc.name, + "kind": doc.kind.id(), + "path": ds.path, + "dirty": ds.is_dirty(), + "currentPage": ds.page, + "selection": ds.selection, + "pages": pages, + "styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::>(), + "variables": doc.variables.variables.len(), + "images": doc.images.len(), + "colorProfile": doc.color_profile.id(), + "textEdit": ds.text_edit, + "history": {"undo": s.undo_labels(20), "redo": s.undo_depth().1}, + }) +} + +/// snake_case object keys → camelCase (inspect output matches the keys `node.set` accepts). +pub fn camel(v: Value) -> Value { + match v { + Value::Object(m) => Value::Object( + m.into_iter() + .map(|(k, v)| { + let mut out = String::new(); + let mut up = false; + for c in k.chars() { + if c == '_' { + up = true; + } else if up { + out.extend(c.to_uppercase()); + up = false; + } else { + out.push(c); + } + } + (out, camel(v)) + }) + .collect(), + ), + Value::Array(a) => Value::Array(a.into_iter().map(camel).collect()), + other => other, + } +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/automation/params.rs similarity index 100% rename from crates/engine/src/params.rs rename to crates/engine/src/automation/params.rs diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs deleted file mode 100644 index 5408c6c94..000000000 --- a/crates/engine/src/board.rs +++ /dev/null @@ -1,1495 +0,0 @@ -//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and -//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and -//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, -//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and -//! live-session edits from others all end in the same geometry. - -use std::collections::{BTreeMap, BTreeSet}; - -use figcraft_color::{BlendMode, Color, Paint}; -use figcraft_doc::{ - AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, - HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, - TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, -}; -use figcraft_geom::kurbo::PathEl; -use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; -use serde_json::{Value, json}; - -use crate::cmd::CommandSpec; -use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; -use crate::tools::{Gesture, PointerEvent, ToolId}; -use crate::{EngineError, Result, Session, bad, spec, textedit}; - -/// Why a board command refuses on a design file. -pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; -/// Why prototyping and Dev Mode refuse on a board. -pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; - -/// A sticky note's side (it grows taller for long notes). -pub const STICKY: f64 = 200.0; -const STICKY_PAD: f64 = 16.0; -/// The room between a sticky and the next one made beside it. -const STICKY_GAP: f64 = 24.0; -/// A new shape's size. -const SHAPE: f64 = 160.0; -/// Text on board objects, and the author line under a sticky's note. -pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); -const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); -/// Connectors' default colour and weight. -const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); -const LINE_WEIGHT: f64 = 2.0; -/// Marker and highlighter strokes: (weight, opacity). -const MARKER: (f64, f32) = (4.0, 1.0); -const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); -/// How far the eraser reaches around the pointer (screen pixels). -const ERASER_PX: f64 = 8.0; -/// The text sizes the contextual bar offers. -pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; - -/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, -/// how new connectors run and the marker's and highlighter's colours. -#[derive(Clone, Debug)] -pub struct BoardTool { - pub sticky: Color, - pub shape: BoardShape, - pub stamp: Stamp, - pub routing: Routing, - pub marker: Color, - pub highlighter: Color, -} - -impl Default for BoardTool { - fn default() -> Self { - let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); - Self { - sticky: first, - shape: BoardShape::Rectangle, - stamp: Stamp::ThumbsUp, - routing: Routing::Elbow, - marker: INK, - highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), - } - } -} - -pub fn specs() -> Vec { - vec![ - spec!( - "board.sticky", - "Sticky note", - "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", - true, - sticky_cmd - ), - spec!( - "board.nextSticky", - "Next sticky note", - "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", - true, - next_sticky - ), - spec!( - "board.shape", - "Shape with text", - "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", - true, - shape_cmd - ), - spec!( - "board.connector", - "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", - true, - connector_cmd - ), - spec!( - "board.reattach", - "Reattach connector", - "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", - true, - reattach - ), - spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), - spec!( - "board.stamp", - "Stamp", - "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", - true, - stamp_cmd - ), - spec!( - "board.ink", - "Marker stroke", - "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", - true, - ink_cmd - ), - spec!( - "board.setColor", - "Board color", - "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", - true, - set_color - ), - spec!( - "board.setStyle", - "Board style", - "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", - true, - set_style - ), - spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { - if let Some(c) = color_param("tool.sticky", p)? { - s.tool.board.sticky = c; - } - board_tool(s, ToolId::Sticky) - }), - spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { - if let Some(v) = string(p, "shape") { - s.tool.board.shape = shape_of("tool.shape", &v)?; - } - board_tool(s, ToolId::BoardShape) - }), - spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { - if let Some(v) = string(p, "style") { - s.tool.board.routing = routing_of("tool.connector", &v)?; - } - board_tool(s, ToolId::Connector) - }), - spec!("tool.marker", "Marker", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.marker", p)? { - s.tool.board.marker = c; - } - board_tool(s, ToolId::Marker) - }), - spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.highlighter", p)? { - s.tool.board.highlighter = c; - } - board_tool(s, ToolId::Highlighter) - }), - spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), - spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { - if let Some(v) = string(p, "stamp") { - s.tool.board.stamp = stamp_of("tool.stamp", &v)?; - } - board_tool(s, ToolId::Stamp) - }), - ] -} - -pub fn is_board(doc: &Document) -> bool { - doc.kind == FileKind::Board -} - -/// Refuse `cmd` unless the active file is a board. -pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { - if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } -} - -/// Prototyping and Dev Mode: not on boards. -fn design_only(cmd: &str) -> bool { - cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") -} - -/// Refuse `cmd` on a board when it is prototyping or Dev Mode. -pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { - if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } -} - -fn board_tool(s: &mut Session, t: ToolId) -> Result { - require(s, &format!("tool.{}", t.id()))?; - crate::cmd::view::tool(s, t) -} - -fn shape_of(cmd: &str, v: &str) -> Result { - BoardShape::from_id(v) - .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) -} - -fn stamp_of(cmd: &str, v: &str) -> Result { - Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) -} - -fn routing_of(cmd: &str, v: &str) -> Result { - Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) -} - -fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { - match get(p, key) { - None | Some(Value::Null) => Ok(None), - Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), - } -} - -/// `color`: a board colour's name, or any colour. -fn color_param(cmd: &str, p: &Value) -> Result> { - match get(p, "color") { - None | Some(Value::Null) => Ok(None), - Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), - Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), - } -} - -/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). -fn line_shade(c: Color) -> Color { - let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); - if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } -} - -/// Your name on what you make: the name from Preferences, else the one a live session gave you -/// (none for the default name outside a session). -pub(crate) fn author(s: &Session) -> String { - let name = crate::live::display_name(&s.prefs); - if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } -} - -/// The parent a command places into, and that parent's page-to-local transform. -fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { - let page = s.page()?; - let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); - let doc = s.doc()?; - let n = doc.get(parent)?; - let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); - if !takes { - return Err(bad(cmd, "`parent` is the page, a section or a frame")); - } - Ok(parent) -} - -/// A new layer at the top of `parent`, at `at` in it, made by `f`. -fn add( - doc: &mut Document, - parent: NodeId, - kind: NodeKind, - name: &str, - at: (f64, f64), - size: (f64, f64), - f: impl FnOnce(&mut Node), -) -> Result { - let id = doc.alloc_id(); - let mut n = Node::new(id, kind, name, size.0, size.1); - n.transform = Affine::translate(at); - f(&mut n); - doc.insert(n, parent, None)?; - Ok(id) -} - -/// A soft shadow under stickies and stamps. -fn soft_shadow() -> Effect { - Effect { - kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), - visible: true, - } -} - -/// A text layer filling its frame's width (the frame's auto layout places it). -pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { - let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); - let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; - let mut td = TextData { - characters: crate::cmd::props::normalize_line_endings(text), - auto_resize: TextAutoResize::Height, - align_h: align, - ..TextData::default() - }; - td.style.font_size = size; - let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { - n.fills = vec![Paint::solid(color)]; - n.child_layout.sizing_h = Sizing::Fill; - })?; - if let Some(n) = doc.node(id) - && let Some((w, h)) = figcraft_text::auto_size(n) - && let Some(n) = doc.node_mut(id) - { - (n.width, n.height) = (w, h); - } - Ok(id) -} - -/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so -/// every copy of the file picks the same size whatever fonts it has). -pub fn sticky_font_size(text: &str) -> f64 { - let weight = text.chars().count() + 24 * text.matches('\n').count(); - match weight { - 0..=24 => 24.0, - 25..=64 => 20.0, - 65..=140 => 16.0, - 141..=280 => 13.0, - _ => 11.0, - } -} - -/// A sticky note's layers: the frame, the note and (when known) its author. -fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { - let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { - n.fills = vec![Paint::solid(color)]; - n.corners.radii = [2.0; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - gap: 8.0, - padding: [STICKY_PAD; 4], - main_align: MainAlign::SpaceBetween, - ..AutoLayout::default() - }); - n.child_layout.sizing_v = Sizing::Hug; - n.child_layout.min_h = Some(STICKY); - n.board = Some(BoardItem::Sticky { author: author.to_string() }); - })?; - let inner = STICKY - 2.0 * STICKY_PAD; - let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; - if !author.is_empty() { - let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; - if let Some(n) = doc.node_mut(a) { - n.name = "Author".into(); - } - } - Ok((id, note)) -} - -fn sticky_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.sticky"; - require(s, cmd)?; - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let author = match get(p, "author") { - Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), - Some(Value::Bool(false)) => String::new(), - _ => author(s), - }; - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": note})) -} - -/// The first text layer inside board object `id` (its note or label). -pub fn text_of(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - n.board.as_ref().filter(|b| b.has_text())?; - n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) -} - -/// The board object whose text `text` is, if any. -pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { - let p = doc.node(text)?.parent?; - (text_of(doc, p) == Some(text)).then_some(p) -} - -/// Start typing in board object `id`'s text (all of it selected). False when it has none. -pub fn edit_text(s: &mut Session, id: NodeId) -> Result { - let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; - textedit::begin(s, t, None, false)?; - textedit::key(s, "SelectAll", false, false, false)?; - Ok(true) -} - -fn next_sticky(s: &mut Session, p: &Value) -> Result { - let cmd = "board.nextSticky"; - require(s, cmd)?; - let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); - let doc = s.doc()?; - let from = id(p, "id") - .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) - .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; - let n = doc.get(from)?; - if !matches!(n.board, Some(BoardItem::Sticky { .. })) { - return Err(bad(cmd, "that isn't a sticky")); - } - let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); - let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; - let at = n.transform.translation(); - let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { - "right" => (n.width + STICKY_GAP, 0.0), - "left" => (-(STICKY + STICKY_GAP), 0.0), - "below" => (0.0, n.height + STICKY_GAP), - "above" => (0.0, -(STICKY + STICKY_GAP)), - other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), - }; - let author = author(s); - if editing.is_some() { - textedit::end(s)?; - } - let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; - s.set_selection(vec![id]); - if boolean(p, "edit") == Some(true) { - // Typing goes on in the new sticky once this step is recorded. - s.request_ui("board.editText", json!({"id": id})); - } - Ok(json!({"id": id, "text": note})) -} - -/// A shape with text's layers: the frame, its outline and its text. -fn make_shape( - doc: &mut Document, - parent: NodeId, - at: (f64, f64), - size: (f64, f64), - shape: BoardShape, - text: &str, - color: Color, -) -> Result<(NodeId, NodeId)> { - let (w, h) = size; - let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { - n.fills.clear(); - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - padding: shape_padding(shape, w, h), - main_align: MainAlign::Center, - cross_align: CrossAlign::Center, - ..AutoLayout::default() - }); - n.board = Some(BoardItem::Shape { shape }); - })?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - n.stroke.weight = 1.5; - n.stroke.align = StrokeAlign::Inside; - n.child_layout.absolute = true; - n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; - })?; - let [_, r, _, l] = shape_padding(shape, w, h); - let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; - Ok((id, label)) -} - -fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { - let [t, r, b, l] = shape.text_insets(); - [t * h, r * w, b * h, l * w] -} - -fn shape_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.shape"; - require(s, cmd)?; - let shape = match string(p, "shape") { - Some(v) => shape_of(cmd, &v)?, - None => s.tool.board.shape, - }; - let size = |k| { - num(p, k) - .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) - }; - let (w, h) = (size("width")?, size("height")?); - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": label})) -} - -fn stamp_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.stamp"; - require(s, cmd)?; - let stamp = match string(p, "stamp") { - Some(v) => stamp_of(cmd, &v)?, - None => s.tool.board.stamp, - }; - let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let author = author(s); - let page = s.page()?; - let half = STAMP_SIZE / 2.0; - let doc = s.doc_mut()?; - let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { - n.fills = vec![Paint::solid(stamp.color())]; - n.corners.radii = [half; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.board = Some(BoardItem::Stamp { stamp, author }); - })?; - for part in stamp.mark() { - let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { - if part.filled { - n.fills = vec![Paint::solid(Color::WHITE)]; - } else { - n.fills.clear(); - n.strokes = vec![Paint::solid(Color::WHITE)]; - n.stroke.weight = 3.0; - (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); - n.stroke.join = StrokeJoin::Round; - n.stroke.align = StrokeAlign::Center; - } - })?; - } - s.set_selection(vec![id]); - Ok(json!({"id": id})) -} - -fn ink_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.ink"; - require(s, cmd)?; - let high = boolean(p, "highlighter").unwrap_or(false); - let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); - let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; - if get(p, "path").is_none() && get(p, "points").is_none() { - return Err(bad(cmd, "`path` or `points` is required")); - } - let page = s.page()?; - let select = boolean(p, "select").unwrap_or(true); - let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { - "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", - }}); - for k in ["path", "points"] { - if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { - o.insert(k.into(), v.clone()); - } - } - let made = crate::cmd::create::create(s, &q)?; - let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; - let n = s.doc_mut()?.get_mut(id)?; - n.opacity = opacity; - if high { - n.blend = BlendMode::Multiply; - } - n.board = Some(BoardItem::Ink); - Ok(json!({"id": id})) -} - -/// 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 { - let side = match string(p, side_key) { - Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, - None => Side::Auto, - }; - match get(p, key) { - Some(Value::Object(o)) if o.contains_key("x") => { - let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); - match (x, y) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), - _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), - } - } - Some(v) => { - let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; - let doc = s.doc()?; - let n = doc.get(node)?; - if n.is_page() || doc.page_of(node) != Some(s.page()?) { - return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); - } - if matches!(n.board, Some(BoardItem::Connector(_))) { - return Err(bad(cmd, format!("`{key}` can't be another connector"))); - } - Ok(End::Node { node, side }) - } - None => Err(bad(cmd, format!("`{key}` is required"))), - } -} - -fn connector_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.connector"; - require(s, cmd)?; - let from = end_param(cmd, s, p, "from", "fromSide")?; - let to = end_param(cmd, s, p, "to", "toSide")?; - if from.node().is_some() && from.node() == to.node() { - return Err(bad(cmd, "connect two different layers")); - } - let routing = match string(p, "style") { - Some(v) => routing_of(cmd, &v)?, - None => s.tool.board.routing, - }; - let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); - let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); - let c = Connector { from, to, routing }; - let page = s.page()?; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { - n.fills.clear(); - n.strokes = vec![Paint::solid(LINE)]; - n.stroke.weight = LINE_WEIGHT; - n.stroke.join = StrokeJoin::Round; - n.stroke.cap_start = start; - n.stroke.cap_end = end; - n.board = Some(BoardItem::Connector(c)); - })?; - place_route(doc, id, &pts, routing); - let mut out = json!({"id": id}); - if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { - out["label"] = json!(set_label(doc, id, &text)?); - } - s.set_selection(vec![id]); - Ok(out) -} - -/// The connector `id` names: a connector, or the label on one. -fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { - match doc.get(id)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((id, c)), - Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((connector, c)), - _ => Err(bad(cmd, "that isn't a connector")), - }, - _ => Err(bad(cmd, "that isn't a connector")), - } -} - -fn reattach(s: &mut Session, p: &Value) -> Result { - let cmd = "board.reattach"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, mut c) = connector_of(cmd, s.doc()?, id)?; - let from = match string(p, "end").as_deref() { - Some("from") => true, - Some("to") => false, - _ => return Err(bad(cmd, "`end` is from or to")), - }; - let end = end_param(cmd, s, p, "target", "side")?; - let other = if from { c.to } else { c.from }; - if end.node().is_some() && end.node() == other.node() { - return Err(bad(cmd, "connect two different layers")); - } - *(if from { &mut c.from } else { &mut c.to }) = end; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); - place_route(doc, id, &pts, c.routing); - Ok(json!({"id": id})) -} - -/// A connector end as `board.connector` and `board.reattach` take it. -fn end_json(e: End) -> Value { - match e { - End::Node { node, .. } => json!(node), - End::Point { x, y } => json!({"x": x, "y": y}), - } -} - -/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. -fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { - match &doc.node(id)?.board { - Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), - _ => None, - } -} - -/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. -pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { - let [id] = sel else { return None }; - if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { - return None; - } - let (a, b) = ends_now(doc, *id)?; - Some((*id, [a, b])) -} - -/// How near (screen pixels) a press must be to a selected connector's end to drag it. -pub const END_HANDLE_PX: f64 = 8.0; - -/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). -pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { - let (id, [a, b]) = selected_connector_ends(doc, sel)?; - let (da, db) = ((a - p).hypot(), (b - p).hypot()); - (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) -} - -/// The label of connector `id`, if it has one. -pub fn label_of(doc: &Document, id: NodeId) -> Option { - let parent = doc.node(id)?.parent?; - doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) -} - -/// Give connector `id` the label `text` (empty removes it); the label's id. -fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { - let existing = label_of(doc, id); - if text.trim().is_empty() { - if let Some(l) = existing { - doc.remove(l)?; - } - return Ok(None); - } - if let Some(l) = existing { - if let Some(t) = text_of(doc, l) - && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) - { - let len = td.char_count(); - td.replace_range(0, len, text); - } - return Ok(Some(l)); - } - let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; - let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { - n.fills = vec![Paint::solid(Color::WHITE)]; - n.corners.radii = [4.0; 4]; - n.clip = false; - n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; - n.board = Some(BoardItem::Label { connector: id }); - })?; - let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; - if let Some(n) = doc.node_mut(t) - && let NodeKind::Text(td) = &mut n.kind - { - // The label hugs its text: one line per paragraph. - td.auto_resize = TextAutoResize::WidthAndHeight; - n.child_layout.sizing_h = Sizing::Hug; - } - Ok(Some(l)) -} - -fn label_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.label"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, _) = connector_of(cmd, s.doc()?, id)?; - let text = req_string(cmd, p, "text")?; - let label = set_label(s.doc_mut()?, id, &text)?; - Ok(json!({"id": id, "label": label})) -} - -/// The board objects among `ids`, each with its item (a label counts as its connector's text). -fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { - ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() -} - -fn set_color(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setColor"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - match item { - BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], - BoardItem::Shape { .. } => { - let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; - let n = doc.get_mut(outline)?; - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - } - BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], - BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, - } - changed.push(id); - } - if changed.is_empty() { - return Err(bad(cmd, "select stickies, shapes, connectors or ink")); - } - Ok(json!({"ids": changed})) -} - -/// What `board.setStyle` changes (each `None` leaves it). -struct Restyle { - shape: Option, - routing: Option, - size: Option, - start: Option, - end: Option, -} - -impl Restyle { - fn of(cmd: &str, p: &Value) -> Result { - let size = num(p, "fontSize") - .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) - .transpose()?; - Ok(Restyle { - shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, - routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, - size, - start: cap_of(cmd, p, "startCap")?, - end: cap_of(cmd, p, "endCap")?, - }) - } - - /// Restyle board object `id` (`item`); whether anything about it changed. - fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { - let changed = match item { - BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { - Some(new) => { - reshape(doc, id, new)?; - true - } - None => false, - }, - BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, - _ => false, - }; - // Text sizes: shapes' and labels' text (stickies size theirs to fit). - let sized = match (self.size, &item) { - (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), - _ => false, - }; - Ok(changed || sized) - } - - fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { - let n = doc.get_mut(id)?; - if let Some(r) = self.routing { - c.routing = r; - n.board = Some(BoardItem::Connector(c)); - } - if let Some(cap) = self.start { - n.stroke.cap_start = cap; - } - if let Some(cap) = self.end { - n.stroke.cap_end = cap; - } - Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) - } -} - -/// Shape with text `id` takes outline `new` (its text room too). -fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { - let n = doc.get_mut(id)?; - n.board = Some(BoardItem::Shape { shape: new }); - n.name = new.label().into(); - let (w, h) = (n.width, n.height); - if let Some(l) = n.layout.as_mut() { - l.padding = shape_padding(new, w, h); - } - if let Some(o) = doc.get(id)?.children.first().copied() - && let Some(on) = doc.node_mut(o) - { - on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; - (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); - } - Ok(()) -} - -/// Board object `id`'s text at `size`; whether it has text. -fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { - let Some(t) = text_of(doc, id) else { return false }; - if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { - set_font_size(td, size); - return true; - } - false -} - -fn set_style(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setStyle"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let restyle = Restyle::of(cmd, p)?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - if restyle.apply(doc, id, item)? { - changed.push(id); - } - } - if changed.is_empty() { - return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); - } - Ok(json!({"ids": changed})) -} - -fn set_font_size(td: &mut TextData, size: f64) { - td.style.font_size = size; - for r in &mut td.runs { - r.style.font_size = size; - } -} - -/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole -/// subtrees pair up when they have the same shape (an instance made of a main component may not; -/// then only the roots do). -pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { - let mut map = BTreeMap::new(); - for (s, c) in sources.iter().zip(copies) { - let (a, b) = (doc.subtree(*s), doc.subtree(*c)); - if a.len() == b.len() { - map.extend(a.into_iter().zip(b)); - } else { - map.insert(*s, *c); - } - } - map -} - -/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the -/// originals in `doc` (`map`: original → copy). -pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { - map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() -} - -/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the -/// originals had them), copied connectors join the copies of what they joined and labels follow -/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a -/// connector's end there stays where it is drawn, as a point, and a label without its connector -/// goes. -pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { - for (copy, item) in originals { - let mut item = item.clone(); - let copied = |n: NodeId| map.contains_key(&n); - match &mut item { - BoardItem::Connector(c) if foreign => { - let ends = ends_now(doc, *copy); - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && !copied(node) - { - *end = End::Point { x: p.x, y: p.y }; - } - } - } - BoardItem::Label { connector } if foreign && !copied(*connector) => { - let _ = doc.remove(*copy); - continue; - } - BoardItem::Connector(_) | BoardItem::Label { .. } => {} - _ => continue, - } - item.remap(map); - if let Some(n) = doc.node_mut(*copy) { - n.board = Some(item); - } - } -} - -// --------------------------------------------------------------------------------------------- -// Derived data (the normalize pass). - -/// Before text is measured: a sticky's note takes the size its length calls for, and a resized -/// shape keeps its text inside its outline. -pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) { - return; - } - for &id in touched { - let Some(n) = doc.node(id) else { continue }; - if let Some(owner) = - owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) - && let Some(t) = text_of(doc, owner) - && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) - { - let size = sticky_font_size(&td.characters); - let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); - if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { - set_font_size(td, size); - } - continue; - } - if let Some(BoardItem::Shape { shape }) = n.board - && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) - { - let pad = shape_padding(shape, n.width, n.height); - if n.layout.as_ref().is_some_and(|l| l.padding != pad) - && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) - { - l.padding = pad; - } - } - } -} - -/// After layout: connectors run between their ends again (an end whose layer is gone stays where -/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only -/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a -/// touched (or gone) end, connector or layer around them. -pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) || touched.is_empty() { - return; - } - let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); - let (mut connectors, mut labels) = (Vec::new(), Vec::new()); - for n in doc.nodes() { - match &n.board { - Some(BoardItem::Connector(c)) => { - let ends = [c.from.node(), c.to.node()].into_iter().flatten(); - if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { - connectors.push((n.id, *c)); - } - } - Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), - _ => {} - } - } - let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); - for (id, mut c) in connectors { - let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); - if changed && let Some(n) = doc.node_mut(id) { - n.board = Some(BoardItem::Connector(c)); - } - if let Some(pts) = route(doc, &c) { - place_route(doc, id, &pts, c.routing); - } - } - for (label, connector) in labels { - if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { - place_label(doc, label, connector); - } - } -} - -/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are -/// drawn, as points. Whether any did. -fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { - let (page, ends) = (doc.page_of(id), ends_now(doc, id)); - let mut detached = false; - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && (!doc.contains(node) || doc.page_of(node) != page) - { - *end = End::Point { x: p.x, y: p.y }; - detached = true; - } - } - detached -} - -/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it -/// joins): its free ends move with it. Whether they did. -fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { - let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; - if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { - return false; - } - let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; - let d = (doc.world_transform(id) * Point::ZERO) - expected; - if d.hypot() <= 1e-9 { - return false; - } - let mut moved = false; - for end in [&mut c.from, &mut c.to] { - if let End::Point { x, y } = end { - (*x, *y) = (*x + d.x, *y + d.y); - moved = true; - } - } - moved -} - -/// Label `label` at the middle of connector `connector`; gone with it when it is gone. -fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { - let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { - let _ = doc.remove(label); - return; - }; - let Some(n) = doc.node(label) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * mid; - let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); - if n.transform != t - && let Some(n) = doc.node_mut(label) - { - n.transform = t; - } -} - -/// Connector `id`'s first and last points on the page as it is drawn now. -fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) -} - -/// The middle of connector `id`'s route by length, on the page. -fn route_mid(doc: &Document, id: NodeId) -> Option { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - let mut pts: Vec = Vec::new(); - figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { - if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { - pts.push(w * p); - } - }); - let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); - let mut left = total / 2.0; - for s in pts.windows(2) { - let (a, b) = (s[0], s[1]); - let l = (b - a).hypot(); - if l >= left && l > 0.0 { - return Some(a.lerp(b, left / l)); - } - left -= l; - } - pts.first().copied() -} - -/// The path through a route's page points `pts` (see [`route`]): one curve for a curved -/// connector, straight runs otherwise. -fn route_path(pts: &[Point], routing: Routing) -> BezPath { - let mut path = BezPath::new(); - match (routing, pts) { - (Routing::Curved, [p0, c0, c1, p1]) => { - path.move_to(*p0); - path.curve_to(*c0, *c1, *p1); - } - _ => { - for (i, p) in pts.iter().enumerate() { - if i == 0 { path.move_to(*p) } else { path.line_to(*p) } - } - } - } - path -} - -/// The box a path is drawn in, when it is finite. -fn finite_box(path: &BezPath) -> Option { - let b = path.bounding_box(); - (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) -} - -/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and -/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. -fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { - let Some(n) = doc.node(id) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * route_path(pts, routing); - let Some(b) = finite_box(&local) else { return }; - let rel = Affine::translate((-b.x0, -b.y0)) * local; - let network = match routing { - Routing::Curved => VectorNetwork::from_path(&rel), - _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), - }; - let t = Affine::translate((b.x0, b.y0)); - let same = - n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); - if same { - return; - } - if let Some(n) = doc.node_mut(id) { - n.kind = NodeKind::Vector { network }; - n.transform = t; - (n.width, n.height) = (b.width(), b.height()); - } -} - -/// How far an elbow connector going around the objects it joins keeps from them. -const ROOM: f64 = 24.0; - -/// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. -pub fn route(doc: &Document, c: &Connector) -> Option> { - let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); - let centre = |e: &End, b: Option| match e { - End::Point { x, y } => Some(Point::new(*x, *y)), - End::Node { .. } => b.map(|b| b.center()), - }; - let (fb, tb) = (bounds(&c.from), bounds(&c.to)); - let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, s0) = attach(&c.from, fb, tc)?; - let (p1, s1) = attach(&c.to, tb, fc)?; - let pts = match c.routing { - Routing::Straight => tidy(vec![p0, p1]), - Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), - Routing::Curved => curve(p0, s0, p1, s1), - }; - pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) -} - -/// `pts` without repeats or turns that don't turn (three in a line across or down). -fn tidy(pts: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(pts.len()); - for p in pts { - if out.last() == Some(&p) { - continue; - } - if let [.., a, b] = out.as_slice() - && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) - { - out.pop(); - } - out.push(p); - } - out -} - -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that -/// side (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { - match e { - End::Point { x, y } => Some((Point::new(*x, *y), None)), - End::Node { side, .. } => { - let b = b?; - let side = match side { - Side::Auto => { - let d = other - b.center(); - // Compare against the box's proportions: a wide box takes the sides more. - if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { - if d.x >= 0.0 { Side::Right } else { Side::Left } - } else if d.y >= 0.0 { - Side::Bottom - } else { - Side::Top - } - } - s => *s, - }; - Some((side.point(b), Some(side))) - } - } -} - -/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no -/// way): the ends and the two control points between them. -fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { - let reach = (p1 - p0).hypot() / 2.0; - let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); - vec![p0, pull(p0, s0), pull(p1, s1), p1] -} - -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a -/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, -/// else the cheapest of a few ways around them (by length, and turns). -fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { - let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); - let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; - if !crosses(&direct, boxes) { - return direct; - } - // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. - let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); - let (a, b) = (out(p0, s0), out(p1, s1)); - let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; - for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { - ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); - } - for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { - ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); - } - let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; - ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) -} - -/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, -/// or out of one, is outside). -fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { - const EDGE: f64 = 0.5; - pts.windows(2).any(|s| { - let (a, b) = (s[0], s[1]); - boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) - }) -} - -/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end -/// follows the other): halfway across between two sides facing the same way, one turn otherwise. -fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { - let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); - let h1 = h1.unwrap_or(h0); - match (h0, h1) { - (true, true) => { - let mx = (p0.x + p1.x) / 2.0; - vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] - } - (false, false) => { - let my = (p0.y + p1.y) / 2.0; - vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] - } - (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], - (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], - } -} - -// --------------------------------------------------------------------------------------------- -// The board tools. - -impl Session { - /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, - /// or start erasing. - pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { - // A board tool kept while a design file came to the front: back to Move for this press. - if !is_board(self.doc()?) { - self.tool.tool = ToolId::Move; - return self.pointer(ev); - } - // A sticky still being typed in ends first, so the next object is an undo step of its own. - if self.ds()?.text_edit.is_some() { - crate::textedit::end(self)?; - } - match self.tool.tool { - ToolId::Sticky => { - let half = STICKY / 2.0; - let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - if let Some(id) = r.get("id").and_then(id_of) { - edit_text(self, id)?; - } - Ok(()) - } - ToolId::BoardShape => { - let half = SHAPE / 2.0; - self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - Ok(()) - } - ToolId::Stamp => { - // Stamps go on one after another: the tool stays. - self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; - Ok(()) - } - ToolId::Marker | ToolId::Highlighter => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; - Ok(()) - } - ToolId::Connector => { - let from = self.end_at(ev); - self.tool.gesture = Gesture::Connect { from, start: ev.p() }; - Ok(()) - } - ToolId::Eraser => { - self.begin_interaction("Erase")?; - self.tool.gesture = Gesture::Erase { gone: Vec::new() }; - self.erase_at(ev) - } - _ => Ok(()), - } - } - - /// After placing an object: back to the Move tool unless tools stay on. - fn board_placed(&mut self) { - if !self.prefs.keep_tool { - self.set_tool(ToolId::Move); - } - } - - /// What a connector end at the pointer attaches to: the object there, or the point. - fn end_at(&self, ev: &PointerEvent) -> End { - let hit = (|| { - let doc = self.doc().ok()?; - let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; - let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); - let n = doc.node(t)?; - (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) - })(); - match hit { - Some(node) => End::Node { node, side: Side::Auto }, - None => End::Point { x: ev.x, y: ev.y }, - } - } - - pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { - let a = match from { - End::Point { x, y } => Point::new(x, y), - End::Node { .. } => start, - }; - self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); - self.tool.overlay.drop_target = from.node(); - self.tool.gesture = Gesture::Connect { from, start }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - self.tool.overlay.drop_target = None; - // A click, not a drag: nothing to join. - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let to = self.end_at(ev); - if to.node().is_some() && to.node() == from.node() { - return Ok(()); - } - let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; - self.board_placed(); - Ok(()) - } - - /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end - /// to the pointer, and the object it would attach to outlined. - pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { - self.tool.gesture = Gesture::Reattach { id, from, start }; - let Ok(doc) = self.doc() else { return }; - let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); - let other = other_end(doc, id, from).and_then(|e| e.node()); - self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); - self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). - /// A click, or a drop on the object the other end is on, changes nothing. - pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let target = self.end_at(ev); - let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); - if target.node().is_some() && target.node() == other { - return Ok(()); - } - self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; - Ok(()) - } - - /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the - /// gesture ends, which is one undo step). - pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { - let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; - let tol = ERASER_PX / ev.zoom.max(1e-6); - self.tool.overlay.brush = Some([ev.x, ev.y, tol]); - let (doc, page) = (self.doc()?.clone(), self.page()?); - if let Some(hit) = - doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) - { - gone.push(hit); - let ids = gone.clone(); - self.preview(|d, sel| { - for i in &ids { - let _ = d.remove(*i); - } - sel.retain(|s| !ids.contains(s)); - Ok(()) - })?; - } - self.tool.gesture = Gesture::Erase { gone }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - Ok(()) - } - - pub(crate) fn erase_up(&mut self) -> Result<()> { - self.tool.overlay.brush = None; - self.commit_interaction() - } - - /// A finished marker or highlighter stroke (page points). - pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { - let high = self.tool.tool == ToolId::Highlighter; - self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; - // Strokes come one after another: the selection stays as it was. - Ok(()) - } -} - -impl Session { - /// A double-click with the Move tool on a board object: type in its text (a connector gets a - /// label to type in). True when it was one. - pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { - let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); - let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - match item { - BoardItem::Connector(_) => { - let label = match label_of(doc, whole) { - Some(l) => l, - None => { - let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; - r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? - } - }; - edit_text(self, label) - } - BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), - b if b.has_text() => edit_text(self, whole), - _ => Ok(false), - } - } -} diff --git a/crates/engine/src/boards.rs b/crates/engine/src/boards.rs new file mode 100644 index 000000000..685c1d70b --- /dev/null +++ b/crates/engine/src/boards.rs @@ -0,0 +1,3 @@ +pub mod board; +pub mod board_table; +pub mod board_templates; diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs new file mode 100644 index 000000000..b40bcc7d6 --- /dev/null +++ b/crates/engine/src/boards/board.rs @@ -0,0 +1,277 @@ +//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and +//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and +//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, +//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and +//! live-session edits from others all end in the same geometry. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::{BlendMode, Color, Paint}; +use figcraft_doc::{ + AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, + HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, + TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, +}; +use figcraft_geom::kurbo::PathEl; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; +use crate::tools::{Gesture, PointerEvent, ToolId}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +mod connectors; +mod derived; +mod edit; +mod items; +mod tools; + +pub(crate) use connectors::end_handle_at; +pub use connectors::{END_HANDLE_PX, selected_connector_ends}; +use connectors::{connector_cmd, reattach}; +pub use derived::route; +pub(crate) use derived::{settle_routes, settle_text}; +pub use edit::label_of; +pub(crate) use edit::{copied_items, copy_map, rejoin}; +use edit::{label_cmd, set_color, set_style}; +pub(crate) use items::add_text; +pub use items::{edit_text, owner_of_text, sticky_font_size, text_of}; +use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; + +/// Why a board command refuses on a design file. +pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; +/// Why prototyping and Dev Mode refuse on a board. +pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; + +/// A sticky note's side (it grows taller for long notes). +pub const STICKY: f64 = 200.0; +const STICKY_PAD: f64 = 16.0; +/// The room between a sticky and the next one made beside it. +const STICKY_GAP: f64 = 24.0; +/// A new shape's size. +const SHAPE: f64 = 160.0; +/// Text on board objects, and the author line under a sticky's note. +pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); +/// Connectors' default colour and weight. +const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); +const LINE_WEIGHT: f64 = 2.0; +/// Marker and highlighter strokes: (weight, opacity). +const MARKER: (f64, f32) = (4.0, 1.0); +const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); +/// How far the eraser reaches around the pointer (screen pixels). +const ERASER_PX: f64 = 8.0; +/// The text sizes the contextual bar offers. +pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; + +/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, +/// how new connectors run and the marker's and highlighter's colours. +#[derive(Clone, Debug)] +pub struct BoardTool { + pub sticky: Color, + pub shape: BoardShape, + pub stamp: Stamp, + pub routing: Routing, + pub marker: Color, + pub highlighter: Color, +} + +impl Default for BoardTool { + fn default() -> Self { + let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); + Self { + sticky: first, + shape: BoardShape::Rectangle, + stamp: Stamp::ThumbsUp, + routing: Routing::Elbow, + marker: INK, + highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), + } + } +} + +pub fn specs() -> Vec { + vec![ + spec!( + "board.sticky", + "Sticky note", + "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", + true, + sticky_cmd + ), + spec!( + "board.nextSticky", + "Next sticky note", + "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + true, + next_sticky + ), + spec!( + "board.shape", + "Shape with text", + "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", + true, + shape_cmd + ), + spec!( + "board.connector", + "Connector", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + true, + connector_cmd + ), + spec!( + "board.reattach", + "Reattach connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + true, + reattach + ), + spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), + spec!( + "board.stamp", + "Stamp", + "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", + true, + stamp_cmd + ), + spec!( + "board.ink", + "Marker stroke", + "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", + true, + ink_cmd + ), + spec!( + "board.setColor", + "Board color", + "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", + true, + set_color + ), + spec!( + "board.setStyle", + "Board style", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", + true, + set_style + ), + spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { + if let Some(c) = color_param("tool.sticky", p)? { + s.tool.board.sticky = c; + } + board_tool(s, ToolId::Sticky) + }), + spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { + if let Some(v) = string(p, "shape") { + s.tool.board.shape = shape_of("tool.shape", &v)?; + } + board_tool(s, ToolId::BoardShape) + }), + spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { + if let Some(v) = string(p, "style") { + s.tool.board.routing = routing_of("tool.connector", &v)?; + } + board_tool(s, ToolId::Connector) + }), + spec!("tool.marker", "Marker", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.marker", p)? { + s.tool.board.marker = c; + } + board_tool(s, ToolId::Marker) + }), + spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.highlighter", p)? { + s.tool.board.highlighter = c; + } + board_tool(s, ToolId::Highlighter) + }), + spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), + spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { + if let Some(v) = string(p, "stamp") { + s.tool.board.stamp = stamp_of("tool.stamp", &v)?; + } + board_tool(s, ToolId::Stamp) + }), + ] +} + +pub fn is_board(doc: &Document) -> bool { + doc.kind == FileKind::Board +} + +/// Refuse `cmd` unless the active file is a board. +pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { + if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } +} + +/// Prototyping and Dev Mode: not on boards. +fn design_only(cmd: &str) -> bool { + cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") +} + +/// Refuse `cmd` on a board when it is prototyping or Dev Mode. +pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { + if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } +} + +fn board_tool(s: &mut Session, t: ToolId) -> Result { + require(s, &format!("tool.{}", t.id()))?; + crate::cmd::view::tool(s, t) +} + +fn shape_of(cmd: &str, v: &str) -> Result { + BoardShape::from_id(v) + .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) +} + +fn stamp_of(cmd: &str, v: &str) -> Result { + Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) +} + +fn routing_of(cmd: &str, v: &str) -> Result { + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) +} + +fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), + } +} + +/// `color`: a board colour's name, or any colour. +fn color_param(cmd: &str, p: &Value) -> Result> { + match get(p, "color") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), + Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), + } +} + +/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). +fn line_shade(c: Color) -> Color { + let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); + if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } +} + +/// Your name on what you make: the name from Preferences, else the one a live session gave you +/// (none for the default name outside a session). +pub(crate) fn author(s: &Session) -> String { + let name = crate::live::display_name(&s.prefs); + if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } +} + +/// The parent a command places into, and that parent's page-to-local transform. +fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { + let page = s.page()?; + let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); + let doc = s.doc()?; + let n = doc.get(parent)?; + let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); + if !takes { + return Err(bad(cmd, "`parent` is the page, a section or a frame")); + } + Ok(parent) +} diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs new file mode 100644 index 000000000..147603310 --- /dev/null +++ b/crates/engine/src/boards/board/connectors.rs @@ -0,0 +1,150 @@ +use super::derived::{ends_now, place_route, route}; +use super::edit::set_label; +use super::items::add; +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 { + let side = match string(p, side_key) { + Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, + None => Side::Auto, + }; + match get(p, key) { + Some(Value::Object(o)) if o.contains_key("x") => { + let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), + _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), + } + } + Some(v) => { + let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; + let doc = s.doc()?; + let n = doc.get(node)?; + if n.is_page() || doc.page_of(node) != Some(s.page()?) { + return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); + } + if matches!(n.board, Some(BoardItem::Connector(_))) { + return Err(bad(cmd, format!("`{key}` can't be another connector"))); + } + Ok(End::Node { node, side }) + } + None => Err(bad(cmd, format!("`{key}` is required"))), + } +} + +pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.connector"; + require(s, cmd)?; + let from = end_param(cmd, s, p, "from", "fromSide")?; + let to = end_param(cmd, s, p, "to", "toSide")?; + if from.node().is_some() && from.node() == to.node() { + return Err(bad(cmd, "connect two different layers")); + } + let routing = match string(p, "style") { + Some(v) => routing_of(cmd, &v)?, + None => s.tool.board.routing, + }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); + let c = Connector { from, to, routing }; + let page = s.page()?; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(LINE)]; + n.stroke.weight = LINE_WEIGHT; + n.stroke.join = StrokeJoin::Round; + n.stroke.cap_start = start; + n.stroke.cap_end = end; + n.board = Some(BoardItem::Connector(c)); + })?; + place_route(doc, id, &pts, routing); + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// The connector `id` names: a connector, or the label on one. +pub(super) fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { + match doc.get(id)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((id, c)), + Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((connector, c)), + _ => Err(bad(cmd, "that isn't a connector")), + }, + _ => Err(bad(cmd, "that isn't a connector")), + } +} + +pub(super) fn reattach(s: &mut Session, p: &Value) -> Result { + let cmd = "board.reattach"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, mut c) = connector_of(cmd, s.doc()?, id)?; + let from = match string(p, "end").as_deref() { + Some("from") => true, + Some("to") => false, + _ => return Err(bad(cmd, "`end` is from or to")), + }; + let end = end_param(cmd, s, p, "target", "side")?; + let other = if from { c.to } else { c.from }; + if end.node().is_some() && end.node() == other.node() { + return Err(bad(cmd, "connect two different layers")); + } + *(if from { &mut c.from } else { &mut c.to }) = end; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); + place_route(doc, id, &pts, c.routing); + Ok(json!({"id": id})) +} + +/// A connector end as `board.connector` and `board.reattach` take it. +pub(super) fn end_json(e: End) -> Value { + match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + } +} + +/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. +pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { + match &doc.node(id)?.board { + Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), + _ => None, + } +} + +/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. +pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { + let [id] = sel else { return None }; + if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { + return None; + } + let (a, b) = ends_now(doc, *id)?; + Some((*id, [a, b])) +} + +/// How near (screen pixels) a press must be to a selected connector's end to drag it. +pub const END_HANDLE_PX: f64 = 8.0; + +/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). +pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { + let (id, [a, b]) = selected_connector_ends(doc, sel)?; + let (da, db) = ((a - p).hypot(), (b - p).hypot()); + (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) +} diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs new file mode 100644 index 000000000..9899632f5 --- /dev/null +++ b/crates/engine/src/boards/board/derived.rs @@ -0,0 +1,342 @@ +use super::edit::set_font_size; +use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; +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). + +/// Before text is measured: a sticky's note takes the size its length calls for, and a resized +/// shape keeps its text inside its outline. +pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) { + return; + } + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + if let Some(owner) = + owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) + && let Some(t) = text_of(doc, owner) + && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) + { + let size = sticky_font_size(&td.characters); + let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); + if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { + set_font_size(td, size); + } + continue; + } + if let Some(BoardItem::Shape { shape }) = n.board + && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) + { + let pad = shape_padding(shape, n.width, n.height); + if n.layout.as_ref().is_some_and(|l| l.padding != pad) + && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) + { + l.padding = pad; + } + } + } +} + +/// After layout: connectors run between their ends again (an end whose layer is gone stays where +/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only +/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a +/// touched (or gone) end, connector or layer around them. +pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) || touched.is_empty() { + return; + } + let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); + let (mut connectors, mut labels) = (Vec::new(), Vec::new()); + for n in doc.nodes() { + match &n.board { + Some(BoardItem::Connector(c)) => { + let ends = [c.from.node(), c.to.node()].into_iter().flatten(); + if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { + connectors.push((n.id, *c)); + } + } + Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), + _ => {} + } + } + let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); + for (id, mut c) in connectors { + let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); + if changed && let Some(n) = doc.node_mut(id) { + n.board = Some(BoardItem::Connector(c)); + } + if let Some(pts) = route(doc, &c) { + place_route(doc, id, &pts, c.routing); + } + } + for (label, connector) in labels { + if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { + place_label(doc, label, connector); + } + } +} + +/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are +/// drawn, as points. Whether any did. +fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { + let (page, ends) = (doc.page_of(id), ends_now(doc, id)); + let mut detached = false; + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && (!doc.contains(node) || doc.page_of(node) != page) + { + *end = End::Point { x: p.x, y: p.y }; + detached = true; + } + } + detached +} + +/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it +/// joins): its free ends move with it. Whether they did. +fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; + if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { + return false; + } + let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; + let d = (doc.world_transform(id) * Point::ZERO) - expected; + if d.hypot() <= 1e-9 { + return false; + } + let mut moved = false; + for end in [&mut c.from, &mut c.to] { + if let End::Point { x, y } = end { + (*x, *y) = (*x + d.x, *y + d.y); + moved = true; + } + } + moved +} + +/// Label `label` at the middle of connector `connector`; gone with it when it is gone. +fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { + let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { + let _ = doc.remove(label); + return; + }; + let Some(n) = doc.node(label) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * mid; + let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); + if n.transform != t + && let Some(n) = doc.node_mut(label) + { + n.transform = t; + } +} + +/// Connector `id`'s first and last points on the page as it is drawn now. +pub(super) fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) +} + +/// The middle of connector `id`'s route by length, on the page. +fn route_mid(doc: &Document, id: NodeId) -> Option { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + let mut pts: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(w * p); + } + }); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let (a, b) = (s[0], s[1]); + let l = (b - a).hypot(); + if l >= left && l > 0.0 { + return Some(a.lerp(b, left / l)); + } + left -= l; + } + pts.first().copied() +} + +/// The path through a route's page points `pts` (see [`route`]): one curve for a curved +/// connector, straight runs otherwise. +fn route_path(pts: &[Point], routing: Routing) -> BezPath { + let mut path = BezPath::new(); + match (routing, pts) { + (Routing::Curved, [p0, c0, c1, p1]) => { + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + } + _ => { + for (i, p) in pts.iter().enumerate() { + if i == 0 { path.move_to(*p) } else { path.line_to(*p) } + } + } + } + path +} + +/// The box a path is drawn in, when it is finite. +fn finite_box(path: &BezPath) -> Option { + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and +/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. +pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { + let Some(n) = doc.node(id) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * route_path(pts, routing); + let Some(b) = finite_box(&local) else { return }; + let rel = Affine::translate((-b.x0, -b.y0)) * local; + let network = match routing { + Routing::Curved => VectorNetwork::from_path(&rel), + _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), + }; + let t = Affine::translate((b.x0, b.y0)); + let same = + n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); + if same { + return; + } + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Vector { network }; + n.transform = t; + (n.width, n.height) = (b.width(), b.height()); + } +} + +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + +/// The page points connector `c` runs through: from the middle of the side of each attached layer +/// facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. +pub fn route(doc: &Document, c: &Connector) -> Option> { + let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); + let centre = |e: &End, b: Option| match e { + End::Point { x, y } => Some(Point::new(*x, *y)), + End::Node { .. } => b.map(|b| b.center()), + }; + let (fb, tb) = (bounds(&c.from), bounds(&c.to)); + let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); + let (p0, s0) = attach(&c.from, fb, tc)?; + let (p1, s1) = attach(&c.to, tb, fc)?; + let pts = match c.routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), + Routing::Curved => curve(p0, s0, p1, s1), + }; + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { + match e { + End::Point { x, y } => Some((Point::new(*x, *y), None)), + End::Node { side, .. } => { + let b = b?; + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => *s, + }; + Some((side.point(b), Some(side))) + } + } +} + +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs new file mode 100644 index 000000000..9456256e7 --- /dev/null +++ b/crates/engine/src/boards/board/edit.rs @@ -0,0 +1,264 @@ +use super::connectors::connector_of; +use super::derived::ends_now; +use super::items::{add, add_text, shape_padding, text_of}; +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 { + let parent = doc.node(id)?.parent?; + doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) +} + +/// Give connector `id` the label `text` (empty removes it); the label's id. +pub(super) fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { + let existing = label_of(doc, id); + if text.trim().is_empty() { + if let Some(l) = existing { + doc.remove(l)?; + } + return Ok(None); + } + if let Some(l) = existing { + if let Some(t) = text_of(doc, l) + && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) + { + let len = td.char_count(); + td.replace_range(0, len, text); + } + return Ok(Some(l)); + } + let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; + let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [4.0; 4]; + n.clip = false; + n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Label { connector: id }); + })?; + let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; + if let Some(n) = doc.node_mut(t) + && let NodeKind::Text(td) = &mut n.kind + { + // The label hugs its text: one line per paragraph. + td.auto_resize = TextAutoResize::WidthAndHeight; + n.child_layout.sizing_h = Sizing::Hug; + } + Ok(Some(l)) +} + +pub(super) fn label_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.label"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, _) = connector_of(cmd, s.doc()?, id)?; + let text = req_string(cmd, p, "text")?; + let label = set_label(s.doc_mut()?, id, &text)?; + Ok(json!({"id": id, "label": label})) +} + +/// The board objects among `ids`, each with its item (a label counts as its connector's text). +fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { + ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() +} + +pub(super) fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setColor"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + match item { + BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], + BoardItem::Shape { .. } => { + let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; + let n = doc.get_mut(outline)?; + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + } + BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], + BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, + } + changed.push(id); + } + if changed.is_empty() { + return Err(bad(cmd, "select stickies, shapes, connectors or ink")); + } + Ok(json!({"ids": changed})) +} + +/// What `board.setStyle` changes (each `None` leaves it). +struct Restyle { + shape: Option, + routing: Option, + size: Option, + start: Option, + end: Option, +} + +impl Restyle { + fn of(cmd: &str, p: &Value) -> Result { + let size = num(p, "fontSize") + .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) + .transpose()?; + Ok(Restyle { + shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, + routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, + size, + start: cap_of(cmd, p, "startCap")?, + end: cap_of(cmd, p, "endCap")?, + }) + } + + /// Restyle board object `id` (`item`); whether anything about it changed. + fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { + let changed = match item { + BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { + Some(new) => { + reshape(doc, id, new)?; + true + } + None => false, + }, + BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, + _ => false, + }; + // Text sizes: shapes' and labels' text (stickies size theirs to fit). + let sized = match (self.size, &item) { + (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), + _ => false, + }; + Ok(changed || sized) + } + + fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { + let n = doc.get_mut(id)?; + if let Some(r) = self.routing { + c.routing = r; + n.board = Some(BoardItem::Connector(c)); + } + if let Some(cap) = self.start { + n.stroke.cap_start = cap; + } + if let Some(cap) = self.end { + n.stroke.cap_end = cap; + } + Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) + } +} + +/// Shape with text `id` takes outline `new` (its text room too). +fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { + let n = doc.get_mut(id)?; + n.board = Some(BoardItem::Shape { shape: new }); + n.name = new.label().into(); + let (w, h) = (n.width, n.height); + if let Some(l) = n.layout.as_mut() { + l.padding = shape_padding(new, w, h); + } + if let Some(o) = doc.get(id)?.children.first().copied() + && let Some(on) = doc.node_mut(o) + { + on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; + (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); + } + Ok(()) +} + +/// Board object `id`'s text at `size`; whether it has text. +fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { + let Some(t) = text_of(doc, id) else { return false }; + if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { + set_font_size(td, size); + return true; + } + false +} + +pub(super) fn set_style(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setStyle"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let restyle = Restyle::of(cmd, p)?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + if restyle.apply(doc, id, item)? { + changed.push(id); + } + } + if changed.is_empty() { + return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); + } + Ok(json!({"ids": changed})) +} + +pub(super) fn set_font_size(td: &mut TextData, size: f64) { + td.style.font_size = size; + for r in &mut td.runs { + r.style.font_size = size; + } +} + +/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole +/// subtrees pair up when they have the same shape (an instance made of a main component may not; +/// then only the roots do). +pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { + let mut map = BTreeMap::new(); + for (s, c) in sources.iter().zip(copies) { + let (a, b) = (doc.subtree(*s), doc.subtree(*c)); + if a.len() == b.len() { + map.extend(a.into_iter().zip(b)); + } else { + map.insert(*s, *c); + } + } + map +} + +/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the +/// originals in `doc` (`map`: original → copy). +pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { + map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() +} + +/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the +/// originals had them), copied connectors join the copies of what they joined and labels follow +/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a +/// connector's end there stays where it is drawn, as a point, and a label without its connector +/// goes. +pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { + for (copy, item) in originals { + let mut item = item.clone(); + let copied = |n: NodeId| map.contains_key(&n); + match &mut item { + BoardItem::Connector(c) if foreign => { + let ends = ends_now(doc, *copy); + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && !copied(node) + { + *end = End::Point { x: p.x, y: p.y }; + } + } + } + BoardItem::Label { connector } if foreign && !copied(*connector) => { + let _ = doc.remove(*copy); + continue; + } + BoardItem::Connector(_) | BoardItem::Label { .. } => {} + _ => continue, + } + item.remap(map); + if let Some(n) = doc.node_mut(*copy) { + n.board = Some(item); + } + } +} diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs new file mode 100644 index 000000000..b76fc8986 --- /dev/null +++ b/crates/engine/src/boards/board/items.rs @@ -0,0 +1,303 @@ +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( + doc: &mut Document, + parent: NodeId, + kind: NodeKind, + name: &str, + at: (f64, f64), + size: (f64, f64), + f: impl FnOnce(&mut Node), +) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, size.0, size.1); + n.transform = Affine::translate(at); + f(&mut n); + doc.insert(n, parent, None)?; + Ok(id) +} + +/// A soft shadow under stickies and stamps. +fn soft_shadow() -> Effect { + Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), + visible: true, + } +} + +/// A text layer filling its frame's width (the frame's auto layout places it). +pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { + let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); + let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; + let mut td = TextData { + characters: crate::cmd::props::normalize_line_endings(text), + auto_resize: TextAutoResize::Height, + align_h: align, + ..TextData::default() + }; + td.style.font_size = size; + let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { + n.fills = vec![Paint::solid(color)]; + n.child_layout.sizing_h = Sizing::Fill; + })?; + if let Some(n) = doc.node(id) + && let Some((w, h)) = figcraft_text::auto_size(n) + && let Some(n) = doc.node_mut(id) + { + (n.width, n.height) = (w, h); + } + Ok(id) +} + +/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so +/// every copy of the file picks the same size whatever fonts it has). +pub fn sticky_font_size(text: &str) -> f64 { + let weight = text.chars().count() + 24 * text.matches('\n').count(); + match weight { + 0..=24 => 24.0, + 25..=64 => 20.0, + 65..=140 => 16.0, + 141..=280 => 13.0, + _ => 11.0, + } +} + +/// A sticky note's layers: the frame, the note and (when known) its author. +fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { + let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { + n.fills = vec![Paint::solid(color)]; + n.corners.radii = [2.0; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + gap: 8.0, + padding: [STICKY_PAD; 4], + main_align: MainAlign::SpaceBetween, + ..AutoLayout::default() + }); + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(STICKY); + n.board = Some(BoardItem::Sticky { author: author.to_string() }); + })?; + let inner = STICKY - 2.0 * STICKY_PAD; + let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; + if !author.is_empty() { + let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; + if let Some(n) = doc.node_mut(a) { + n.name = "Author".into(); + } + } + Ok((id, note)) +} + +pub(super) fn sticky_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.sticky"; + require(s, cmd)?; + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let author = match get(p, "author") { + Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), + Some(Value::Bool(false)) => String::new(), + _ => author(s), + }; + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": note})) +} + +/// The first text layer inside board object `id` (its note or label). +pub fn text_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + n.board.as_ref().filter(|b| b.has_text())?; + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) +} + +/// The board object whose text `text` is, if any. +pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { + let p = doc.node(text)?.parent?; + (text_of(doc, p) == Some(text)).then_some(p) +} + +/// Start typing in board object `id`'s text (all of it selected). False when it has none. +pub fn edit_text(s: &mut Session, id: NodeId) -> Result { + let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; + textedit::begin(s, t, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} + +pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { + let cmd = "board.nextSticky"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + let n = doc.get(from)?; + if !matches!(n.board, Some(BoardItem::Sticky { .. })) { + return Err(bad(cmd, "that isn't a sticky")); + } + let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); + let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; + let at = n.transform.translation(); + let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { + "right" => (n.width + STICKY_GAP, 0.0), + "left" => (-(STICKY + STICKY_GAP), 0.0), + "below" => (0.0, n.height + STICKY_GAP), + "above" => (0.0, -(STICKY + STICKY_GAP)), + other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), + }; + let author = author(s); + if editing.is_some() { + textedit::end(s)?; + } + let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + // Typing goes on in the new sticky once this step is recorded. + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": note})) +} + +/// A shape with text's layers: the frame, its outline and its text. +fn make_shape( + doc: &mut Document, + parent: NodeId, + at: (f64, f64), + size: (f64, f64), + shape: BoardShape, + text: &str, + color: Color, +) -> Result<(NodeId, NodeId)> { + let (w, h) = size; + let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { + n.fills.clear(); + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + padding: shape_padding(shape, w, h), + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + ..AutoLayout::default() + }); + n.board = Some(BoardItem::Shape { shape }); + })?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + n.stroke.weight = 1.5; + n.stroke.align = StrokeAlign::Inside; + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + })?; + let [_, r, _, l] = shape_padding(shape, w, h); + let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; + Ok((id, label)) +} + +pub(super) fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { + let [t, r, b, l] = shape.text_insets(); + [t * h, r * w, b * h, l * w] +} + +pub(super) fn shape_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.shape"; + require(s, cmd)?; + let shape = match string(p, "shape") { + Some(v) => shape_of(cmd, &v)?, + None => s.tool.board.shape, + }; + let size = |k| { + num(p, k) + .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) + }; + let (w, h) = (size("width")?, size("height")?); + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": label})) +} + +pub(super) fn stamp_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.stamp"; + require(s, cmd)?; + let stamp = match string(p, "stamp") { + Some(v) => stamp_of(cmd, &v)?, + None => s.tool.board.stamp, + }; + let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let author = author(s); + let page = s.page()?; + let half = STAMP_SIZE / 2.0; + let doc = s.doc_mut()?; + let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { + n.fills = vec![Paint::solid(stamp.color())]; + n.corners.radii = [half; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.board = Some(BoardItem::Stamp { stamp, author }); + })?; + for part in stamp.mark() { + let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { + if part.filled { + n.fills = vec![Paint::solid(Color::WHITE)]; + } else { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::WHITE)]; + n.stroke.weight = 3.0; + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); + n.stroke.join = StrokeJoin::Round; + n.stroke.align = StrokeAlign::Center; + } + })?; + } + s.set_selection(vec![id]); + Ok(json!({"id": id})) +} + +pub(super) fn ink_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.ink"; + require(s, cmd)?; + let high = boolean(p, "highlighter").unwrap_or(false); + let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); + let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; + if get(p, "path").is_none() && get(p, "points").is_none() { + return Err(bad(cmd, "`path` or `points` is required")); + } + let page = s.page()?; + let select = boolean(p, "select").unwrap_or(true); + let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { + "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", + }}); + for k in ["path", "points"] { + if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { + o.insert(k.into(), v.clone()); + } + } + let made = crate::cmd::create::create(s, &q)?; + let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; + let n = s.doc_mut()?.get_mut(id)?; + n.opacity = opacity; + if high { + n.blend = BlendMode::Multiply; + } + n.board = Some(BoardItem::Ink); + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs new file mode 100644 index 000000000..cb3fc0056 --- /dev/null +++ b/crates/engine/src/boards/board/tools.rs @@ -0,0 +1,213 @@ +use super::connectors::{end_json, other_end}; +use super::derived::ends_now; +use super::edit::label_of; +use super::items::edit_text; +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. + +impl Session { + /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, + /// or start erasing. + pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { + // A board tool kept while a design file came to the front: back to Move for this press. + if !is_board(self.doc()?) { + self.tool.tool = ToolId::Move; + return self.pointer(ev); + } + // A sticky still being typed in ends first, so the next object is an undo step of its own. + if self.ds()?.text_edit.is_some() { + crate::textedit::end(self)?; + } + match self.tool.tool { + ToolId::Sticky => { + let half = STICKY / 2.0; + let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + if let Some(id) = r.get("id").and_then(id_of) { + edit_text(self, id)?; + } + Ok(()) + } + ToolId::BoardShape => { + let half = SHAPE / 2.0; + self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + Ok(()) + } + ToolId::Stamp => { + // Stamps go on one after another: the tool stays. + self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; + Ok(()) + } + ToolId::Marker | ToolId::Highlighter => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Connector => { + let from = self.end_at(ev); + self.tool.gesture = Gesture::Connect { from, start: ev.p() }; + Ok(()) + } + ToolId::Eraser => { + self.begin_interaction("Erase")?; + self.tool.gesture = Gesture::Erase { gone: Vec::new() }; + self.erase_at(ev) + } + _ => Ok(()), + } + } + + /// After placing an object: back to the Move tool unless tools stay on. + fn board_placed(&mut self) { + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + + /// What a connector end at the pointer attaches to: the object there, or the point. + fn end_at(&self, ev: &PointerEvent) -> End { + let hit = (|| { + let doc = self.doc().ok()?; + let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); + let n = doc.node(t)?; + (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + })(); + match hit { + Some(node) => End::Node { node, side: Side::Auto }, + None => End::Point { x: ev.x, y: ev.y }, + } + } + + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { + let a = match from { + End::Point { x, y } => Point::new(x, y), + End::Node { .. } => start, + }; + self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); + self.tool.overlay.drop_target = from.node(); + self.tool.gesture = Gesture::Connect { from, start }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + self.tool.overlay.drop_target = None; + // A click, not a drag: nothing to join. + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let to = self.end_at(ev); + if to.node().is_some() && to.node() == from.node() { + return Ok(()); + } + let style = self.tool.board.routing.id(); + self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; + self.board_placed(); + Ok(()) + } + + /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end + /// to the pointer, and the object it would attach to outlined. + pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { + self.tool.gesture = Gesture::Reattach { id, from, start }; + let Ok(doc) = self.doc() else { return }; + let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); + let other = other_end(doc, id, from).and_then(|e| e.node()); + self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); + self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). + /// A click, or a drop on the object the other end is on, changes nothing. + pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let target = self.end_at(ev); + let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); + if target.node().is_some() && target.node() == other { + return Ok(()); + } + self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + Ok(()) + } + + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the + /// gesture ends, which is one undo step). + pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { + let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; + let tol = ERASER_PX / ev.zoom.max(1e-6); + self.tool.overlay.brush = Some([ev.x, ev.y, tol]); + let (doc, page) = (self.doc()?.clone(), self.page()?); + if let Some(hit) = + doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) + { + gone.push(hit); + let ids = gone.clone(); + self.preview(|d, sel| { + for i in &ids { + let _ = d.remove(*i); + } + sel.retain(|s| !ids.contains(s)); + Ok(()) + })?; + } + self.tool.gesture = Gesture::Erase { gone }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } + + pub(crate) fn erase_up(&mut self) -> Result<()> { + self.tool.overlay.brush = None; + self.commit_interaction() + } + + /// A finished marker or highlighter stroke (page points). + pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { + let high = self.tool.tool == ToolId::Highlighter; + self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; + // Strokes come one after another: the selection stays as it was. + Ok(()) + } +} + +impl Session { + /// A double-click with the Move tool on a board object: type in its text (a connector gets a + /// label to type in). True when it was one. + pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { + let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); + let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + match item { + BoardItem::Connector(_) => { + let label = match label_of(doc, whole) { + Some(l) => l, + None => { + let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; + r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? + } + }; + edit_text(self, label) + } + BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), + b if b.has_text() => edit_text(self, whole), + _ => Ok(false), + } + } +} diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/boards/board_table.rs similarity index 100% rename from crates/engine/src/board_table.rs rename to crates/engine/src/boards/board_table.rs diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/boards/board_templates.rs similarity index 100% rename from crates/engine/src/board_templates.rs rename to crates/engine/src/boards/board_templates.rs diff --git a/crates/engine/src/canvas.rs b/crates/engine/src/canvas.rs new file mode 100644 index 000000000..b513ffbde --- /dev/null +++ b/crates/engine/src/canvas.rs @@ -0,0 +1,14 @@ +pub mod al_keys; +pub mod arc_handles; +pub mod booleans; +pub mod distance; +pub mod drop; +pub mod guides; +pub mod rotation_origin; +pub mod shape_handles; +pub mod shapebuilder; +pub mod snap; +pub mod textedit; +pub mod textpreview; +pub mod tools; +pub mod vectoredit; diff --git a/crates/engine/src/al_keys.rs b/crates/engine/src/canvas/al_keys.rs similarity index 100% rename from crates/engine/src/al_keys.rs rename to crates/engine/src/canvas/al_keys.rs diff --git a/crates/engine/src/arc_handles.rs b/crates/engine/src/canvas/arc_handles.rs similarity index 100% rename from crates/engine/src/arc_handles.rs rename to crates/engine/src/canvas/arc_handles.rs diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/canvas/booleans.rs similarity index 100% rename from crates/engine/src/booleans.rs rename to crates/engine/src/canvas/booleans.rs diff --git a/crates/engine/src/distance.rs b/crates/engine/src/canvas/distance.rs similarity index 100% rename from crates/engine/src/distance.rs rename to crates/engine/src/canvas/distance.rs diff --git a/crates/engine/src/drop.rs b/crates/engine/src/canvas/drop.rs similarity index 99% rename from crates/engine/src/drop.rs rename to crates/engine/src/canvas/drop.rs index d5e8b3cfd..9e054630e 100644 --- a/crates/engine/src/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/guides.rs b/crates/engine/src/canvas/guides.rs similarity index 100% rename from crates/engine/src/guides.rs rename to crates/engine/src/canvas/guides.rs diff --git a/crates/engine/src/rotation_origin.rs b/crates/engine/src/canvas/rotation_origin.rs similarity index 100% rename from crates/engine/src/rotation_origin.rs rename to crates/engine/src/canvas/rotation_origin.rs diff --git a/crates/engine/src/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs similarity index 100% rename from crates/engine/src/shape_handles.rs rename to crates/engine/src/canvas/shape_handles.rs diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs similarity index 99% rename from crates/engine/src/shapebuilder.rs rename to crates/engine/src/canvas/shapebuilder.rs index 99d59b456..74a4a9d12 100644 --- a/crates/engine/src/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/snap.rs b/crates/engine/src/canvas/snap.rs similarity index 100% rename from crates/engine/src/snap.rs rename to crates/engine/src/canvas/snap.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/canvas/textedit.rs similarity index 53% rename from crates/engine/src/textedit.rs rename to crates/engine/src/canvas/textedit.rs index 2cd9bf972..91e411e54 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -796,576 +796,4 @@ pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> { } #[cfg(test)] -mod tests { - use super::*; - use crate::tools::{PointerEvent, PointerKind}; - use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; - use serde_json::json; - - fn setup(text: &str) -> (Session, NodeId) { - let mut s = Session::new(); - let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); - let id = crate::params::id_of(&r["id"]).unwrap(); - (s, id) - } - - fn data(s: &Session, id: NodeId) -> TextData { - s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() - } - - fn sel(s: &Session) -> (usize, usize) { - let te = s.ds().unwrap().text_edit.clone().unwrap(); - (te.anchor, te.caret) - } - - #[test] - fn whole_editing_session_is_one_undo_step() { - let (mut s, id) = setup("Hello"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - insert(&mut s, " big").unwrap(); - insert(&mut s, " world").unwrap(); - key(&mut s, "Backspace", false, false, false).unwrap(); - // Styling a range mid-session joins the same step. - set_caret(&mut s, 0, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.bold", &json!({})).unwrap(); - s.execute("text.alignCenter", &json!({})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello big worl"); - assert_eq!(t.style_at(0).font_style, "Bold"); - assert_eq!(t.style_at(6).font_style, "Regular"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - s.undo().unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello"); - assert!(t.runs.is_empty()); - assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); - } - - #[test] - fn typing_replaces_selection_and_paste_is_plain() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, None, false).unwrap(); - assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); - select_word(&mut s, 7).unwrap(); - assert_eq!(sel(&s), (6, 11)); - insert(&mut s, "there").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there"); - assert_eq!(sel(&s), (11, 11)); - // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. - insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - insert(&mut s, "\u{1}").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - // Select the line (paragraph) then type over it. - select_line(&mut s, 0).unwrap(); - assert_eq!(sel(&s), (0, 11)); - insert(&mut s, "Hi").unwrap(); - assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); - assert_eq!(selected_text(&s).as_deref(), Some("")); - end(&mut s).unwrap(); - } - - #[test] - fn word_selection_classes() { - let (mut s, id) = setup("foo, bar_baz!\nnext"); - begin(&mut s, id, Some(0), false).unwrap(); - select_word(&mut s, 1).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Right after a word's last char selects the word. - select_word(&mut s, 13).unwrap(); - assert_eq!(sel(&s), (6, 13)); - // A run of spaces. - select_word(&mut s, 4).unwrap(); - assert_eq!(sel(&s), (4, 6)); - select_line(&mut s, 16).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_line(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_word(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - set_caret(&mut s, 999, false).unwrap(); - assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); - } - - #[test] - fn keyboard_navigation_and_extension() { - let (mut s, id) = setup("one two\nthree"); - begin(&mut s, id, Some(0), false).unwrap(); - key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends - assert_eq!(sel(&s), (3, 7)); - key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end - assert_eq!(sel(&s), (7, 7)); - key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end - assert_eq!(sel(&s), (7, 13)); - key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start - assert_eq!(sel(&s), (0, 0)); - key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward - assert_eq!(data(&s, id).characters, " two\nthree"); - key(&mut s, "SelectAll", false, false, false).unwrap(); - assert_eq!(sel(&s), (0, 10)); - end(&mut s).unwrap(); - } - - #[test] - fn backspace_removes_only_the_final_thai_mark() { - let (mut s, id) = setup("น้ำ"); - begin(&mut s, id, Some(3), false).unwrap(); - - key(&mut s, "Backspace", false, false, false).unwrap(); - - assert_eq!(data(&s, id).characters, "น้"); - } - - fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { - let mut e = PointerEvent::at(kind, x, y, 1.0); - e.shift = shift; - e - } - - /// Asserts double-clicking a text layer selects all text, a second double-click selects the - /// word without changing the editing session, and drag and Shift+click extend the - /// selection. - #[test] - fn pointer_double_click_drag_and_shift_click() { - let (mut s, id) = setup("Hello world"); - let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; - if l.glyphs.is_empty() { - return; // no fonts - } - let y = l.lines[0].top + l.lines[0].height / 2.0; - let x_of = |i: usize| l.caret(i).0; - // Double-click inside "world" edits with all the text selected; another one there - // selects the word. - let mid = (x_of(7) + x_of(8)) / 2.0; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 11)); - let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (6, 11)); - assert_eq!( - s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, - epoch, - "a double-click inside the edit is an ordinary edit: same IME replacement epoch" - ); - // Drag-select from the start of "Hello" to the middle. - s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 0)); - s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Shift+click extends. - s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); - s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); - assert_eq!(sel(&s), (0, 9)); - insert(&mut s, "J").unwrap(); - assert_eq!(data(&s, id).characters, "Jld"); - } - - #[test] - fn text_commands_apply_to_edited_range() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 6, true).unwrap(); // "Hello " - for (cmd, p) in [ - ("text.underline", json!({})), - ("text.italic", json!({})), - ("text.sizeUp", json!({})), - ("text.trackingUp", json!({})), - ("text.lineHeightUp", json!({})), - ("text.caseSmallCaps", json!({})), - ("text.link", json!({"url": "https://example.com"})), - ] { - s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); - } - let t = data(&s, id); - let (a, b) = (t.style_at(0), t.style_at(8)); - assert_eq!(a.decoration, TextDecoration::Underline); - assert!(a.italic() && !b.italic()); - assert_eq!(a.font_size, 25.0); - assert_eq!(b.font_size, 24.0); - assert_eq!(a.case, TextCase::SmallCaps); - assert_eq!(a.link.as_deref(), Some("https://example.com")); - assert!(b.link.is_none()); - // text.style with an explicit range (what the panel sends). - s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(8).font_size, 40.0); - assert_eq!(t.style_at(8).case, TextCase::Upper); - // Paragraph commands hit the edited node. - s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); - assert_eq!(data(&s, id).list, ListStyle::Numbered); - // Every Text-menu command runs while editing. - for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { - let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; - if crate::find_command(item.id).is_some() { - s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); - } - } - end(&mut s).unwrap(); - } - - #[test] - fn paragraph_and_feature_commands_on_nodes() { - let (mut s, id) = setup("Hello"); - let ids = json!([id]); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); - assert_eq!(data(&s, id).features.get("liga"), Some(&false)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); - let f = data(&s, id).features; - assert_eq!(f.get("liga"), None); - assert_eq!(f.get("ss01"), Some(&true)); - assert_eq!(f.get("tnum"), Some(&true)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); - s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); - s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); - s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); - s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); - s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); - let t = data(&s, id); - assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); - assert_eq!(t.auto_resize, TextAutoResize::Truncate); - assert_eq!(t.style.decoration, TextDecoration::Strikethrough); - assert_eq!(t.style.case, TextCase::SmallCaps); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); - assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); - assert_eq!(data(&s, id).paragraph_indent, 4.0); - } - - fn preedit_state(s: &Session) -> Option { - s.ds().unwrap().text_edit.clone().unwrap().preedit - } - - #[test] - fn ime_composition_is_shown_but_not_in_the_document_until_committed() { - let (mut s, id) = setup("Hello world"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - let doc_before = s.ds().unwrap().doc.clone(); - s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); - assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); - assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); - let (shown, range, cursor) = preedit_doc(&s).unwrap(); - assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); - assert_eq!((range, cursor), ((5, 7), 6)); - let info = s.execute("text.edit.selection", &json!({})).unwrap(); - assert_eq!(info["preedit"]["text"], "にほ"); - // Updating the composition replaces it; an empty one cancels it. - preedit(&mut s, "にほん", None).unwrap(); - assert_eq!(preedit_doc(&s).unwrap().2, 8); - preedit(&mut s, "", None).unwrap(); - assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello world"); - // Commit puts the final text at the caret. - preedit(&mut s, "にほん", None).unwrap(); - s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本 world"); - assert_eq!(sel(&s), (7, 7)); - // Composing over a selection replaces it; moving the caret abandons the composition. - set_caret(&mut s, 7, false).unwrap(); - set_caret(&mut s, 13, true).unwrap(); - preedit(&mut s, "x", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - set_caret(&mut s, 0, false).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本"); - // Ending the edit drops an uncommitted composition; the session stays one undo step. - preedit(&mut s, "zz", None).unwrap(); - end(&mut s).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - // Not editing: an error, not a crash. - assert!(preedit(&mut s, "a", None).is_err()); - } - - #[test] - fn variable_font_axes_as_commands_and_props() { - let (mut s, id) = setup("Hello world"); - let ids = json!([id]); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); - let st = data(&s, id).style; - assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); - assert_eq!(st.weight(), 550); - // null removes one axis; replace clears the rest. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); - assert_eq!(data(&s, id).style.variations.len(), 1); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); - // Undo restores the previous values. - s.undo().unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); - // Junk is rejected. - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); - // node.set props (whole text or a range) replace the custom values. - s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); - assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); - s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); - assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); - // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); - s.execute("text.bold", &json!({"ids": ids})).unwrap(); - let st = data(&s, id).style; - assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); - assert!(!st.variations.contains_key("wght")); - assert_eq!(st.variations.get("wdth"), Some(&90.0)); - // While editing, the command styles the selected range only. - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); - assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); - // The axes query answers without fonts too. - let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); - assert!(r.is_null() || r["axes"].is_array()); - } - - /// No text command panics on junk parameters while a text is being edited. - #[test] - fn text_commands_survive_junk_while_editing() { - let junk = [ - json!({}), - json!(null), - json!({"index": -5, "features": {"": 1}}), - json!({"index": 1e300, "style": 3, "case": null}), - json!([1]), - json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), - json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), - ]; - // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { - for j in &junk { - let (mut s, id) = setup("Hi there\nyou"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { - panic!("{cmd} panicked on {j}: {msg}"); - } - } - } - } - - /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed - /// selection reversed. - #[test] - fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { - let (mut s, id) = setup("café😀e\u{301}x"); - begin(&mut s, id, Some(8), false).unwrap(); - set_caret(&mut s, 8, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "café😀e\u{301}x"); - assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); - assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); - assert_eq!(ime.owner.node, id); - assert_eq!(ime.owner.document, s.ds().unwrap().uid); - } - - /// Asserts an active composition is excluded from surrounding text and an empty text layer - /// reports an available, empty payload. - #[test] - fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - preedit(&mut s, "にほ", Some(1)).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "Hi", "the composition is not committed text"); - assert_eq!((ime.cursor, ime.anchor), (2, 2)); - end(&mut s).unwrap(); - - let (mut s, id) = setup(""); - begin(&mut s, id, Some(0), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); - } - - /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized - /// selection is describable, and one byte more is `SelectionTooWide`. - #[test] - fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { - let long = "a".repeat(5000); - let (mut s, id) = setup(&long); - begin(&mut s, id, Some(5000), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); - assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); - - // Exactly the budget is still describable; one more byte is not. - set_caret(&mut s, 1000, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), 4000); - assert_eq!((ime.cursor, ime.anchor), (4000, 0)); - set_caret(&mut s, 999, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); - } - - /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new - /// edit session changes the generation. - #[test] - fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(0), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - insert(&mut s, "x").unwrap(); - set_caret(&mut s, 2, false).unwrap(); - assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); - end(&mut s).unwrap(); - begin(&mut s, id, Some(0), false).unwrap(); - let replaced = ime_surrounding(&s).unwrap().owner; - assert_eq!(replaced.node, id); - assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); - } - - /// Asserts byte deletion around a reversed selection removes whole characters on both - /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. - #[test] - fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(4), false).unwrap(); - set_caret(&mut s, 4, false).unwrap(); - set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - // "b" (whole character before the selection) and "c" (whole character after it) go; the - // selected "你好" stays for the paired commit, and the reversed direction is preserved. - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "你好"); - assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed - assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); - assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); - } - - /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an - /// active composition and the revision untouched. - #[test] - fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 4, true).unwrap(); // "你好" - assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); - set_caret(&mut s, 0, false).unwrap(); - // A composition in progress survives a delete that finds nothing to remove. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); - assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision); - } - - /// Asserts a text layer in another document has a different IME owner. - #[test] - fn ime_owner_identity_changes_with_the_document() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - end(&mut s).unwrap(); - s.execute("file.new", &json!({"name": "Second"})).unwrap(); - let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); - let other = crate::params::id_of(&r["id"]).unwrap(); - begin(&mut s, other, Some(5), false).unwrap(); - let second = ime_surrounding(&s).unwrap().owner; - assert_ne!(second.document, first.document, "another document is another IME owner"); - assert_eq!(second.node, other); - } - - /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports - /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. - #[test] - fn ime_commit_reports_list_autoformat_as_an_application_transformation() { - let (mut s, id) = setup("-"); - begin(&mut s, id, Some(1), false).unwrap(); - assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, ""); - assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); - assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } - - /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, - /// and bare newline/CR commits are ignored while a live composition remains. - #[test] - fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - // CRLF is inserted as LF, but the editor changed the commit: application-origin. - assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Control characters are dropped; the commit itself is a no-op. - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. - // (It also leaves a live composition alone, unlike the non-ignored commit below.) - preedit(&mut s, "x", None).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); - assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); - preedit(&mut s, "", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb"); - insert(&mut s, "\n").unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb\n"); - } - - /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision - /// once), inserts nothing, and is a harmless no-op when no composition is active. - #[test] - fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(5), false).unwrap(); - preedit(&mut s, "にほ", None).unwrap(); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); - assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); - assert!(preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); - - // A control-only commit normalises to empty and ends the composition too. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello"); - - // With no composition (and not even while editing) an empty commit stays a harmless no-op. - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision); - end(&mut s).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } -} +mod tests; diff --git a/crates/engine/src/canvas/textedit/tests.rs b/crates/engine/src/canvas/textedit/tests.rs new file mode 100644 index 000000000..fecca5808 --- /dev/null +++ b/crates/engine/src/canvas/textedit/tests.rs @@ -0,0 +1,571 @@ +use super::*; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; +use serde_json::json; + +fn setup(text: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); + let id = crate::params::id_of(&r["id"]).unwrap(); + (s, id) +} + +fn data(s: &Session, id: NodeId) -> TextData { + s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() +} + +fn sel(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +#[test] +fn whole_editing_session_is_one_undo_step() { + let (mut s, id) = setup("Hello"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + insert(&mut s, " big").unwrap(); + insert(&mut s, " world").unwrap(); + key(&mut s, "Backspace", false, false, false).unwrap(); + // Styling a range mid-session joins the same step. + set_caret(&mut s, 0, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.bold", &json!({})).unwrap(); + s.execute("text.alignCenter", &json!({})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello big worl"); + assert_eq!(t.style_at(0).font_style, "Bold"); + assert_eq!(t.style_at(6).font_style, "Regular"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + s.undo().unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello"); + assert!(t.runs.is_empty()); + assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); +} + +#[test] +fn typing_replaces_selection_and_paste_is_plain() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, None, false).unwrap(); + assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); + select_word(&mut s, 7).unwrap(); + assert_eq!(sel(&s), (6, 11)); + insert(&mut s, "there").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there"); + assert_eq!(sel(&s), (11, 11)); + // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. + insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + insert(&mut s, "\u{1}").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + // Select the line (paragraph) then type over it. + select_line(&mut s, 0).unwrap(); + assert_eq!(sel(&s), (0, 11)); + insert(&mut s, "Hi").unwrap(); + assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); + assert_eq!(selected_text(&s).as_deref(), Some("")); + end(&mut s).unwrap(); +} + +#[test] +fn word_selection_classes() { + let (mut s, id) = setup("foo, bar_baz!\nnext"); + begin(&mut s, id, Some(0), false).unwrap(); + select_word(&mut s, 1).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Right after a word's last char selects the word. + select_word(&mut s, 13).unwrap(); + assert_eq!(sel(&s), (6, 13)); + // A run of spaces. + select_word(&mut s, 4).unwrap(); + assert_eq!(sel(&s), (4, 6)); + select_line(&mut s, 16).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_line(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_word(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + set_caret(&mut s, 999, false).unwrap(); + assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); +} + +#[test] +fn keyboard_navigation_and_extension() { + let (mut s, id) = setup("one two\nthree"); + begin(&mut s, id, Some(0), false).unwrap(); + key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends + assert_eq!(sel(&s), (3, 7)); + key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end + assert_eq!(sel(&s), (7, 7)); + key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end + assert_eq!(sel(&s), (7, 13)); + key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start + assert_eq!(sel(&s), (0, 0)); + key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward + assert_eq!(data(&s, id).characters, " two\nthree"); + key(&mut s, "SelectAll", false, false, false).unwrap(); + assert_eq!(sel(&s), (0, 10)); + end(&mut s).unwrap(); +} + +#[test] +fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); +} + +fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + e.shift = shift; + e +} + +/// Asserts double-clicking a text layer selects all text, a second double-click selects the +/// word without changing the editing session, and drag and Shift+click extend the +/// selection. +#[test] +fn pointer_double_click_drag_and_shift_click() { + let (mut s, id) = setup("Hello world"); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; // no fonts + } + let y = l.lines[0].top + l.lines[0].height / 2.0; + let x_of = |i: usize| l.caret(i).0; + // Double-click inside "world" edits with all the text selected; another one there + // selects the word. + let mid = (x_of(7) + x_of(8)) / 2.0; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); + // Drag-select from the start of "Hello" to the middle. + s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 0)); + s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Shift+click extends. + s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); + s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); + assert_eq!(sel(&s), (0, 9)); + insert(&mut s, "J").unwrap(); + assert_eq!(data(&s, id).characters, "Jld"); +} + +#[test] +fn text_commands_apply_to_edited_range() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 6, true).unwrap(); // "Hello " + for (cmd, p) in [ + ("text.underline", json!({})), + ("text.italic", json!({})), + ("text.sizeUp", json!({})), + ("text.trackingUp", json!({})), + ("text.lineHeightUp", json!({})), + ("text.caseSmallCaps", json!({})), + ("text.link", json!({"url": "https://example.com"})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + } + let t = data(&s, id); + let (a, b) = (t.style_at(0), t.style_at(8)); + assert_eq!(a.decoration, TextDecoration::Underline); + assert!(a.italic() && !b.italic()); + assert_eq!(a.font_size, 25.0); + assert_eq!(b.font_size, 24.0); + assert_eq!(a.case, TextCase::SmallCaps); + assert_eq!(a.link.as_deref(), Some("https://example.com")); + assert!(b.link.is_none()); + // text.style with an explicit range (what the panel sends). + s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(8).font_size, 40.0); + assert_eq!(t.style_at(8).case, TextCase::Upper); + // Paragraph commands hit the edited node. + s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); + assert_eq!(data(&s, id).list, ListStyle::Numbered); + // Every Text-menu command runs while editing. + for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { + let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; + if crate::find_command(item.id).is_some() { + s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); + } + } + end(&mut s).unwrap(); +} + +#[test] +fn paragraph_and_feature_commands_on_nodes() { + let (mut s, id) = setup("Hello"); + let ids = json!([id]); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); + assert_eq!(data(&s, id).features.get("liga"), Some(&false)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); + let f = data(&s, id).features; + assert_eq!(f.get("liga"), None); + assert_eq!(f.get("ss01"), Some(&true)); + assert_eq!(f.get("tnum"), Some(&true)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); + s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); + s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); + s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); + s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); + s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); + let t = data(&s, id); + assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); + assert_eq!(t.auto_resize, TextAutoResize::Truncate); + assert_eq!(t.style.decoration, TextDecoration::Strikethrough); + assert_eq!(t.style.case, TextCase::SmallCaps); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); + assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); + assert_eq!(data(&s, id).paragraph_indent, 4.0); +} + +fn preedit_state(s: &Session) -> Option { + s.ds().unwrap().text_edit.clone().unwrap().preedit +} + +#[test] +fn ime_composition_is_shown_but_not_in_the_document_until_committed() { + let (mut s, id) = setup("Hello world"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + let doc_before = s.ds().unwrap().doc.clone(); + s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); + assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); + assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); + let (shown, range, cursor) = preedit_doc(&s).unwrap(); + assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); + assert_eq!((range, cursor), ((5, 7), 6)); + let info = s.execute("text.edit.selection", &json!({})).unwrap(); + assert_eq!(info["preedit"]["text"], "にほ"); + // Updating the composition replaces it; an empty one cancels it. + preedit(&mut s, "にほん", None).unwrap(); + assert_eq!(preedit_doc(&s).unwrap().2, 8); + preedit(&mut s, "", None).unwrap(); + assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello world"); + // Commit puts the final text at the caret. + preedit(&mut s, "にほん", None).unwrap(); + s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本 world"); + assert_eq!(sel(&s), (7, 7)); + // Composing over a selection replaces it; moving the caret abandons the composition. + set_caret(&mut s, 7, false).unwrap(); + set_caret(&mut s, 13, true).unwrap(); + preedit(&mut s, "x", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + set_caret(&mut s, 0, false).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本"); + // Ending the edit drops an uncommitted composition; the session stays one undo step. + preedit(&mut s, "zz", None).unwrap(); + end(&mut s).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + // Not editing: an error, not a crash. + assert!(preedit(&mut s, "a", None).is_err()); +} + +#[test] +fn variable_font_axes_as_commands_and_props() { + let (mut s, id) = setup("Hello world"); + let ids = json!([id]); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); + let st = data(&s, id).style; + assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); + assert_eq!(st.weight(), 550); + // null removes one axis; replace clears the rest. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); + assert_eq!(data(&s, id).style.variations.len(), 1); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); + // Undo restores the previous values. + s.undo().unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); + // Junk is rejected. + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); + // node.set props (whole text or a range) replace the custom values. + s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); + assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); + s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); + assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); + // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); + s.execute("text.bold", &json!({"ids": ids})).unwrap(); + let st = data(&s, id).style; + assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); + assert!(!st.variations.contains_key("wght")); + assert_eq!(st.variations.get("wdth"), Some(&90.0)); + // While editing, the command styles the selected range only. + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); + assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); + // The axes query answers without fonts too. + let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); + assert!(r.is_null() || r["axes"].is_array()); +} + +/// No text command panics on junk parameters while a text is being edited. +#[test] +fn text_commands_survive_junk_while_editing() { + let junk = [ + json!({}), + json!(null), + json!({"index": -5, "features": {"": 1}}), + json!({"index": 1e300, "style": 3, "case": null}), + json!([1]), + json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), + json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), + ]; + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { + for j in &junk { + let (mut s, id) = setup("Hi there\nyou"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed +/// selection reversed. +#[test] +fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); +} + +/// Asserts an active composition is excluded from surrounding text and an empty text layer +/// reports an available, empty payload. +#[test] +fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); +} + +/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized +/// selection is describable, and one byte more is `SelectionTooWide`. +#[test] +fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); +} + +/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new +/// edit session changes the generation. +#[test] +fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); +} + +/// Asserts byte deletion around a reversed selection removes whole characters on both +/// sides, keeps the selected text and direction, and a final no-op leaves the text alone. +#[test] +fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); +} + +/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an +/// active composition and the revision untouched. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); +} + +/// Asserts a text layer in another document has a different IME owner. +#[test] +fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); +} + +/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports +/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. +#[test] +fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} + +/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, +/// and bare newline/CR commits are ignored while a live composition remains. +#[test] +fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); +} + +/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision +/// once), inserts nothing, and is a harmless no-op when no composition is active. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/canvas/textpreview.rs similarity index 91% rename from crates/engine/src/textpreview.rs rename to crates/engine/src/canvas/textpreview.rs index da101d5ea..07a09a159 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/canvas/textpreview.rs @@ -39,7 +39,14 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> { ds.text_edit = text_edit; match result { Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => { - normalize(ds.doc_mut(), &base); + // A panic while normalising shows no preview (#1085). + if let Err(e) = guard::run(command, || { + normalize(ds.doc_mut(), &base); + Ok(()) + }) { + ds.doc = base; + return Err(e); + } ds.text_preview = Some(base); ds.revision += 1; Ok(()) diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs new file mode 100644 index 000000000..b1dc3eff3 --- /dev/null +++ b/crates/engine/src/canvas/tools.rs @@ -0,0 +1,578 @@ +//! Pointer tools. The UI (or an agent through the control channel) feeds [`PointerEvent`]s in page +//! coordinates; tools turn gestures into document edits through interactions (one undo step per +//! gesture) and publish overlay hints (marquee, guides, size label, cursor) for the canvas. + +use std::collections::BTreeMap; + +use figcraft_color::{ImageScaleMode, PaintKind}; +use figcraft_doc::geometry::select_target; +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; + +use crate::arc_handles::{ArcHandle, ArcHint}; +use crate::cmd::arrange::move_world; +use crate::cmd::create::frame_at; +use crate::cmd::edit::roots; +use crate::shape_handles::{ShapeHandle, ShapeHint}; +use crate::snap::{Guide, Targets}; +use crate::{Result, Session, textedit}; + +mod click; +mod drag; +mod draw; +mod handles; +mod keys; +mod pen; +mod pointer; + +use handles::DRAG_SLOP_PX; +#[cfg(test)] +pub(crate) use handles::scale_factor; +pub use handles::{ + PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, + selection_box, selection_frame, +}; +pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +#[cfg(test)] +pub(crate) use pen::pressure_profile; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum ToolId { + #[default] + Move, + Scale, + Hand, + Frame, + Section, + Slice, + Rectangle, + Line, + Arrow, + Ellipse, + Polygon, + Star, + Pen, + Pencil, + Text, + TextOnPath, + Comment, + Eyedropper, + ShapeBuilder, + // Board tools. + Sticky, + BoardShape, + Connector, + Marker, + Highlighter, + Eraser, + Stamp, +} + +impl ToolId { + pub const ALL: [ToolId; 26] = [ + ToolId::Move, + ToolId::Scale, + ToolId::Hand, + ToolId::Frame, + ToolId::Section, + ToolId::Slice, + ToolId::Rectangle, + ToolId::Line, + ToolId::Arrow, + ToolId::Ellipse, + ToolId::Polygon, + ToolId::Star, + ToolId::Pen, + ToolId::Pencil, + ToolId::Text, + ToolId::TextOnPath, + ToolId::Comment, + ToolId::Eyedropper, + ToolId::ShapeBuilder, + ToolId::Sticky, + ToolId::BoardShape, + ToolId::Connector, + ToolId::Marker, + ToolId::Highlighter, + ToolId::Eraser, + ToolId::Stamp, + ]; + pub fn id(self) -> &'static str { + match self { + ToolId::Move => "move", + ToolId::Scale => "scale", + ToolId::Hand => "hand", + ToolId::Frame => "frame", + ToolId::Section => "section", + ToolId::Slice => "slice", + ToolId::Rectangle => "rectangle", + ToolId::Line => "line", + ToolId::Arrow => "arrow", + ToolId::Ellipse => "ellipse", + ToolId::Polygon => "polygon", + ToolId::Star => "star", + ToolId::Pen => "pen", + ToolId::Pencil => "pencil", + ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", + ToolId::Comment => "comment", + ToolId::Eyedropper => "eyedropper", + ToolId::ShapeBuilder => "shapeBuilder", + ToolId::Sticky => "sticky", + ToolId::BoardShape => "shape", + ToolId::Connector => "connector", + ToolId::Marker => "marker", + ToolId::Highlighter => "highlighter", + ToolId::Eraser => "eraser", + ToolId::Stamp => "stamp", + } + } + pub fn from_id(s: &str) -> Option { + ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + ToolId::Move => "Move", + ToolId::Scale => "Scale", + ToolId::Hand => "Hand tool", + ToolId::Frame => "Frame", + ToolId::Section => "Section", + ToolId::Slice => "Slice", + ToolId::Rectangle => "Rectangle", + ToolId::Line => "Line", + ToolId::Arrow => "Arrow", + ToolId::Ellipse => "Ellipse", + ToolId::Polygon => "Polygon", + ToolId::Star => "Star", + ToolId::Pen => "Pen", + ToolId::Pencil => "Pencil", + ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", + ToolId::Comment => "Comment", + ToolId::Eyedropper => "Pick color", + ToolId::ShapeBuilder => "Shape builder", + ToolId::Sticky => "Sticky note", + ToolId::BoardShape => "Shapes", + ToolId::Connector => "Connector", + ToolId::Marker => "Marker", + ToolId::Highlighter => "Highlighter", + ToolId::Eraser => "Eraser", + ToolId::Stamp => "Stamp", + } + } + + /// The tools of a board's toolbar (a design file's toolbar has the others). + pub fn on_boards(self) -> bool { + matches!( + self, + ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp + ) + } + /// The tool's key (from [`crate::keymap`]; empty when it has none). + pub fn shortcut(self) -> &'static str { + let id = self.id(); + crate::keymap::KEYS + .iter() + .find(|b| if self == ToolId::Eyedropper { b.id == "edit.pickColor" } else { b.id.strip_prefix("tool.") == Some(id) }) + .and_then(|b| b.keys(crate::keymap::Platform::current()).first().copied()) + .unwrap_or("") + } + + /// The size a click (or Enter, `tool.place`) makes with a shape or frame tool. + pub fn click_size(self) -> (f64, f64) { + match self { + ToolId::Line | ToolId::Arrow => (100.0, 0.0), + ToolId::Section => (400.0, 300.0), + _ => (100.0, 100.0), + } + } + + pub fn draws_shape(self) -> bool { + matches!( + self, + ToolId::Frame + | ToolId::Section + | ToolId::Slice + | ToolId::Rectangle + | ToolId::Ellipse + | ToolId::Polygon + | ToolId::Star + | ToolId::Line + | ToolId::Arrow + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum PointerKind { + Down, + Move, + Up, + /// Pointer moved without a button. + Hover, + DoubleClick, +} + +/// A pointer event in page coordinates (and screen coordinates for panning). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PointerEvent { + pub kind: PointerKind, + pub x: f64, + pub y: f64, + /// Screen position (logical pixels). + pub sx: f64, + pub sy: f64, + pub shift: bool, + pub alt: bool, + /// Cmd on macOS, Ctrl elsewhere. + pub cmd: bool, + pub ctrl: bool, + /// Screen pixels per page unit. + pub zoom: f64, + /// Pen pressure, 0..1, where the device reports it (`None`: a mouse, or a platform that doesn't). + pub pressure: Option, +} + +/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. +fn within_slop(ev: &PointerEvent, start: Point) -> bool { + (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX +} + +impl PointerEvent { + pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self { + Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None } + } + pub(crate) fn p(&self) -> Point { + Point::new(self.x, self.y) + } + fn tol(&self) -> f64 { + 1.0 / self.zoom.max(1e-6) + } +} + +/// Cursor hint for the UI. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Default)] +pub enum Cursor { + #[default] + Default, + Crosshair, + Move, + /// Resize along an angle (degrees, 0 = horizontal). + Resize(f64), + /// Rotate, turned to the corner it is near: the direction from the box's centre to the corner + /// (while rotating: to the pointer), in degrees, 0 = right and 90 = down. + Rotate(f64), + Grab, + Grabbing, + Text, + Pen(PenCursor), + /// A drag here makes a copy (Alt). + Copy, + /// Over a clickable part of a widget. + Pointer, + Eyedropper, + Comment, + /// What the gesture is doing will be undone when it ends (an edit only a main component can + /// make). + NotAllowed, +} + +/// What a click with the pen would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum PenCursor { + /// A new point. + #[default] + New, + /// On a point: closes the path, connects to the point or goes on from it. + Close, + /// On a segment: adds a point there. + Add, + /// Bends the segment or the point's handles (the Bend tool; Cmd/Ctrl in vector edit mode). + Bend, +} + +impl ToolId { + /// The tool's cursor over empty canvas. + pub fn cursor(self) -> Cursor { + match self { + ToolId::Hand => Cursor::Grab, + ToolId::Pen => Cursor::Pen(PenCursor::New), + ToolId::Comment => Cursor::Comment, + ToolId::Eyedropper => Cursor::Eyedropper, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, + // The other tools make something where they are pressed: shapes, frames, slices, pencil + // strokes, a new text (over a text the Text tool shows the I-beam instead). + _ => Cursor::Crosshair, + } + } +} + +/// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the +/// rotation zones outside the corners. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum Handle { + Corner(u8), + Edge(u8), + Rotate(u8), +} + +/// What the canvas should draw on top of the scene. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Overlay { + pub marquee: Option<[f64; 4]>, + pub guides: Vec, + pub cursor: Cursor, + /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. + pub pen: Vec<[f64; 2]>, + pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, + /// Frame/section the pointer is drawing or dropping into (highlighted). + pub drop_target: Option, + /// Auto layout insertion indicator (x0, y0, x1, y1). + pub insert_line: Option<[f64; 4]>, + /// The instance an Alt-drop of a component from the Assets panel would swap (highlighted). + pub swap_target: Option, + /// Auto layout grid cell a drag would drop into (x0, y0, x1, y1). + pub drop_cell: Option<[f64; 4]>, + /// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the + /// paint bucket would fill (page-space polygons). + pub vector_hover: Option, + /// The edited layer `vector_hover` is on (several layers can be in edit mode). + pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, + /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. + pub width_hover: Option, + pub lasso: Vec<[f64; 2]>, + /// The eraser brush under the pointer (x, y, radius in page units). + pub brush: Option<[f64; 3]>, + pub face: Vec>, + /// Shape builder: the regions under the pointer or collected by the drag (indexes into + /// [`crate::shapebuilder::current`]) and whether releasing removes them. + pub builder_regions: Vec, + pub builder_remove: bool, + /// Measuring (Alt): the box of the layer or frame measured to (x0, y0, x1, y1), outlined. + pub measure_box: Option<[f64; 4]>, + /// Scale tool drag: the factor so far and the point that stays put. + pub scale: Option, + /// The selected ellipse's arc handle under the pointer, and the readout while one is dragged + /// (see [`crate::arc_handles`]). + pub arc_hover: Option, + pub arc: Option, + /// The selected shape's corner radius or star ratio handle under the pointer, and the readout + /// while one is dragged (see [`crate::shape_handles`]). + pub shape_hover: Option, + pub shape: Option, +} + +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + +/// The Scale tool's live readout while dragging a handle. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct ScaleHint { + pub factor: f64, + pub pivot: [f64; 2], +} + +#[derive(Clone, Debug)] +pub(crate) struct ResizeStart { + /// Node id → (world transform, w, h) at the start. + nodes: Vec<(NodeId, Affine, f64, f64)>, + /// The handle box: page transform of its top-left corner (axes along the box) and its size. + frame: (Affine, f64, f64), + /// One layer (resized in place) rather than several (mapped from the old box to the new). + single: bool, + handle: Handle, +} + +#[derive(Clone, Debug, Default)] +pub(crate) enum Gesture { + #[default] + None, + Marquee { + start: Point, + /// Shift: toggle what it touches in the selection it started from (`base`). + additive: bool, + base: Vec, + within: Option, + }, + Move { + start: Point, + ids: Vec, + targets: Targets, + duplicate: bool, + moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, + }, + Resize { + start: Point, + r: Box, + scale_tool: bool, + moved: bool, + }, + Rotate { + center: Point, + start_angle: f64, + ids: Vec, + }, + Draw { + start: Point, + id: Option, + parent: NodeId, + targets: Targets, + }, + Pan { + sx: f64, + sy: f64, + cx: f64, + cy: f64, + }, + Pencil { + points: Vec, + /// The pen pressure at each point, where the device reports it. + pressure: Vec>, + }, + TextSelect { + id: NodeId, + }, + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). + TextBox { + id: NodeId, + start: Point, + line: f64, + }, + PenDrag { + anchor: Point, + }, + /// Vector edit mode gestures (handled in `vectoredit`). + Vector(crate::vectoredit::VGesture), + /// Dragging an ellipse's arc handle: the arc so far (see [`crate::arc_handles`]). + Arc { + id: NodeId, + handle: ArcHandle, + arc: crate::arc_handles::Arc, + }, + /// Dragging a shape's corner radius or star ratio handle (see [`crate::shape_handles`]). + Shape { + id: NodeId, + handle: ShapeHandle, + }, + /// Dragging the rotation origin handle (see [`crate::rotation_origin`]). + Origin, + /// The connector tool: dragging from `from` (pressed at `start`). + Connect { + from: figcraft_doc::End, + start: Point, + }, + /// Dragging the selected connector `id`'s end (`from`: its start, pressed at `start`) to + /// attach it elsewhere. + Reattach { + id: NodeId, + from: bool, + start: Point, + }, + /// The eraser: the strokes it took so far. + Erase { + gone: Vec, + }, + /// Shape builder: regions collected so far (handled in `shapebuilder`). + ShapeBuild { + picked: Vec, + remove: bool, + start: Point, + last: Point, + }, +} + +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + +/// The pen tool's in-progress path (page coordinates). +#[derive(Clone, Debug, Default)] +pub struct PenPath { + pub id: Option, + pub parent: Option, + /// Anchors with (in, out) handle offsets. + pub points: Vec<(Point, Vec2, Vec2)>, + pub closed: bool, + /// The path ends connected to this anchor of its own (a vector network). + pub join: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ToolState { + pub tool: ToolId, + pub(crate) gesture: Gesture, + pub overlay: Overlay, + pub pen: Option, + /// Tool to return to after a temporary tool (space → hand). + pub temp_from: Option, + /// Last click (for double-click detection by agents that only send downs). + pub last_hover: Option, + /// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click + /// removes; Alt merges). + pub shape_builder: Option>, + pub builder_remove: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, + /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). + pub(crate) pen_targets: Option, + /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the + /// selection box that stays put (`tool.scaleAnchor`). + pub scale_anchor: (u8, u8), + /// The last drag move handled (a held button repeats it every frame). + pub(crate) last_move: Option, + /// The name labels of top-level frames and sections as the canvas last drew them (each label's + /// corners on the page, turned with its frame): the Move and Scale tools treat a press or + /// hover on one as on its frame. + pub labels: Vec<(NodeId, [Point; 4])>, + /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). + pub pencil_brush: Option, + /// The board tools' colours and choices. + pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, +} + +impl ToolState { + pub fn busy(&self) -> bool { + !matches!(self.gesture, Gesture::None) + } +} diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs new file mode 100644 index 000000000..a8291f0f2 --- /dev/null +++ b/crates/engine/src/canvas/tools/click.rs @@ -0,0 +1,347 @@ +use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; +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, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + if self.tool.tool == ToolId::Pen { + return self.pen_finish(); + } + let edits = !self.tool.dev_mode; + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } + // Quick clicks on a widget's clickable part are clicks, one by one. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { + self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; + self.cancel_interaction()?; + } + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the + // pointer (an open path or a line has no inside: only its stroke counts). + if edits + && let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { + return Ok(()); + } + let target = select_target(&doc, leaf, &sel, true); + // Text: edit at the click. + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); + let idx = self.text_index_at(target, ev.p())?; + if editing { + textedit::select_word(self, idx)?; + } else { + textedit::begin(self, target, Some(idx), false)?; + textedit::key(self, "SelectAll", false, false, false)?; + } + return Ok(()); + } + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); + } + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); + if deeper != target || !sel.contains(&deeper) { + self.set_selection(vec![deeper]); + } + Ok(()) + } + + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + pub(super) fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + + /// The char index in text node `id` at page point `p`. + pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { + let doc = self.doc()?; + let n = doc.get(id)?; + let local = doc.world_transform(id).inverse() * p; + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; + Ok(layout.hit(local.x, local.y - dy)) + } + + pub(super) fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, extend)?; + self.tool.gesture = Gesture::TextSelect { id }; + Ok(()) + } + + pub(super) fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + if let Some(te) = ds.text_edit.clone() { + if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Clicking existing text edits it. + if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + { + let idx = self.text_index_at(leaf, ev.p())?; + textedit::begin(self, leaf, Some(idx), false)?; + self.tool.gesture = Gesture::TextSelect { id: leaf }; + return Ok(()); + } + // New text at the click (Figma puts the baseline-ish top-left there). + let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); + let pw = d.world_transform(parent); + let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); + n.transform = pw.inverse() * Affine::translate(at.to_vec2()); + n.name = String::new(); + if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + if let Some((w, h)) = figcraft_text::auto_size(&n) { + n.width = w.max(1.0); + n.height = h; + } + let line = n.height; + d.insert(n, parent, None)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + pub(super) fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + pub(super) fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + + pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; + let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; + let rgba = figcraft_render::to_rgba8(&pix); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); + let sel = ds.selection.clone(); + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { + self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; + } + self.recent_colors.retain(|x| *x != c); + self.recent_colors.insert(0, c); + self.recent_colors.truncate(16); + self.message = Some(format!("Picked #{}", c.to_hex())); + self.set_tool(ToolId::Move); + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs new file mode 100644 index 000000000..d1db8f92d --- /dev/null +++ b/crates/engine/src/canvas/tools/drag.rs @@ -0,0 +1,609 @@ +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::{ + 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<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + // Clicking outside the text being edited ends editing. + if let Some(te) = ds.text_edit.clone() { + let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); + if inside { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. + let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } + if self.arc_handles_shown() + && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) + && let Some(arc) = crate::arc_handles::arc_of(&doc, id) + { + self.begin_interaction("Arc")?; + self.tool.gesture = Gesture::Arc { id, handle, arc }; + self.tool.overlay.arc = + crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); + return Ok(()); + } + if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) + && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) + { + self.begin_interaction("Corner radius")?; + self.tool.gesture = Gesture::Shape { id, handle }; + self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); + return Ok(()); + } + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { + let ids = roots(&doc, &sel); + if let Handle::Rotate(_) = h { + let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; + let center = self + .ds()? + .moved_origin() + .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); + self.begin_interaction("Rotate")?; + self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + return Ok(()); + } + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; + let single = ids.len() == 1; + self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, frame, single, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + moved: false, + }; + return Ok(()); + } + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) + }; + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, the frame or canvas behind it, or a layer stacked below the selected layer the + // press is in) drags the selection. In a gap between selected layers, what's there is hit. + let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); + let behind_sel = |t: Option| { + t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) + }; + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; + match target { + Some(target) => { + // Clicking inside the current selection's box keeps it (drag them all). + let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); + let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + sel.clone() + } else if ev.shift && deselect.is_none() { + let mut s2 = sel.clone(); + s2.push(target); + s2 + } else if sel.contains(&target) { + sel.clone() + } else { + vec![target] + }; + self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; + } + None => { + let within = self.marquee_within(ev.p())?; + if !ev.shift { + self.set_selection(vec![]); + } + self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; + } + } + Ok(()) + } + + pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + let res = match &g { + Gesture::None => return self.hover(ev), + Gesture::Marquee { start, additive, base, within } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + // Cmd/Ctrl: the nested layers it touches. + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; + self.set_selection(sel); + Ok(()) + } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), + Gesture::Move { start, ids, targets, duplicate, .. } => { + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let mut guides = Vec::new(); + if snap_on { + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; + guides = g2; + } + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); + } + let ids = ids.clone(); + let dup = *duplicate; + let page = self.page()?; + let mut drop_cell = None; + let mut drop_target = None; + let mut in_flow = false; + // Placed in a row or column: (is it a column, its gap) for the insertion line. + let mut slot_axis: Option<(bool, f64)> = None; + self.preview(|doc, sel| { + let mut moving = ids.clone(); + if dup { + let mut copies = Vec::new(); + for i in &ids { + let parent = doc.get(*i)?.parent.unwrap_or(page); + let idx = doc.index_in_parent(*i).map(|x| x + 1); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); + } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); + moving = copies; + *sel = moving.clone(); + } + let p = ev.p(); + let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); + let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; + let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); + if let (true, Some(mode)) = (flow, mode) { + // Into (or within) an auto layout frame: the layers take the slot (a grid: the + // cell) under the pointer, as the frame shows it now. A frame other than the + // one they come from is outlined too. + let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; + let idx = + if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; + for m in &moving { + if doc.get(*m)?.parent != Some(target) { + doc.reparent(*m, target, None)?; + } + } + if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { + let cl = &mut doc.get_mut(first)?.child_layout; + cl.grid_column = Some(col); + cl.grid_row = Some(row); + drop_cell = Some(rect); + } else if mode != LayoutMode::Grid { + let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); + for (k, m) in moving.iter().enumerate() { + kids.insert((idx + k).min(kids.len()), *m); + } + let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); + doc.get_mut(target)?.children = kids; + slot_axis = Some((mode == LayoutMode::Vertical, gap)); + } + drop_target = (Some(target) != home).then_some(target); + in_flow = true; + return Ok(()); + } + // A free move. Layers dragged out of an auto layout flow leave it (onto the page + // while dragging; the drop puts them in the frame under the pointer). + for m in &moving { + let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flow && in_al { + doc.reparent(*m, page, None)?; + } + move_world(doc, *m, d.x, d.y)?; + } + drop_target = (flow && target != page).then_some(target); + Ok(()) + })?; + let placed = if dup { self.selection() } else { ids }; + // Snapping guides mean nothing while the layout places the layers; with Alt held, + // live distances to the neighbours replace them. + self.tool.overlay.guides = if in_flow { + Vec::new() + } else if ev.alt { + let doc = self.doc()?; + doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() + } else { + guides + }; + // The preview already shows the layers in their slot: the line marks it there, in + // the middle of the gap before them (clear of the selection box). + self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { + let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; + self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) + }); + self.tool.overlay.drop_cell = drop_cell; + self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; + self.tool.gesture = g.clone(); + if let Gesture::Move { moved, .. } = &mut self.tool.gesture { + *moved = true; + } + return Ok(()); + } + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } + return res; + } + Gesture::Rotate { center, start_angle, ids } => { + let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); + if ev.shift { + a = (a / 15.0).round() * 15.0; + } + let (c, ids) = (*center, ids.clone()); + self.preview(|doc, _| { + for i in &ids { + crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; + } + Ok(()) + })?; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; + Ok(()) + } + Gesture::Draw { start, id, parent, targets } => { + let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); + self.tool.gesture = g; + return self.draw_drag(ev, start, id, parent, &targets); + } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } + Gesture::Arc { id, handle, arc } => { + let (id, handle, arc) = (*id, *handle, *arc); + self.tool.gesture = g; + return self.arc_drag(ev, id, handle, arc); + } + Gesture::Shape { id, handle } => { + let (id, handle) = (*id, *handle); + let doc = self.doc()?.clone(); + if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + } + self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); + self.tool.overlay.shape_hover = Some(handle); + Ok(()) + } + Gesture::Pan { sx, sy, cx, cy } => { + let ds = self.ds_mut()?; + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - (ev.sx - sx) / z; + ds.view.cy = cy - (ev.sy - sy) / z; + ds.revision += 1; + Ok(()) + } + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); + if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + pts.push(ev.p()); + pressure.push(ev.pressure); + } + self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + Gesture::TextSelect { id } => { + let id = *id; + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, true)?; + Ok(()) + } + Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } + Gesture::PenDrag { anchor } => { + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; + if let Some(pen) = self.tool.pen.as_mut() + && let Some(last) = pen.points.last_mut() + { + last.2 = out; + last.1 = -out; + } + self.pen_update()?; + Ok(()) + } + }; + self.tool.gesture = g; + res + } + + /// An arc handle drag: the new arc, written through `node.set`'s keys. + fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { + let doc = self.doc()?.clone(); + let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; + let props = crate::arc_handles::props(next); + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + self.tool.gesture = Gesture::Arc { id, handle, arc: next }; + let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); + self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); + self.tool.overlay.arc_hover = Some(handle); + Ok(()) + } + + fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { + let page = self.page()?; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); + let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; + let mut p = ev.p(); + let mut guides = Vec::new(); + if let Some(t) = &targets { + let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } + let q = frame.inverse() * p; + let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + if scale_tool { + return self.scale_drag(ev, r, q, guides); + } + if is_line { + // Line end handles: move the end point freely (length + angle). + let (a, b) = match r.handle { + Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), + _ => (frame * Point::new(0.0, 0.0), p), + }; + let id = ids.first().copied().unwrap_or(NodeId(0)); + self.preview(|doc, _| { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + let len = v.hypot(); + let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + doc.set_world_transform(id, world)?; + figcraft_layout::set_size(doc, id, len, 0.0); + Ok(()) + })?; + self.tool.overlay.guides = guides; + return Ok(()); + } + // Constrain proportions (Shift, or the node's lock). + let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); + let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); + let (flip_x, flip_y) = (x1 < x0, y1 < y0); + let (nx0, ny0) = (x0.min(x1), y0.min(y1)); + let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); + let nodes = r.nodes.clone(); + let single = r.single; + let handle = r.handle; + // What's in a section stays where it is on the page, and so does what's in a frame resized + // with Cmd/Ctrl held (its constraints are ignored). + let keeps = |doc: &Document, id: NodeId| { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) + }; + self.preview(|doc, _| { + if single { + let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + let mut t = *world * Affine::translate((nx0, ny0)); + if flip_x { + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + } + if flip_y { + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + } + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). + let n = doc.get(*id)?; + let crop_images = ev.cmd + && !matches!(n.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } + doc.set_world_transform(*id, t)?; + if kept.is_empty() { + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + } else { + figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); + crate::cmd::props::restore_worlds(doc, &kept)?; + } + let n = doc.get_mut(*id)?; + if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { + n.child_layout.sizing_h = Sizing::Fixed; + } + if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { + n.child_layout.sizing_v = Sizing::Fixed; + } + if let NodeKind::Text(t) = &mut n.kind { + match handle { + Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} + Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, + _ => t.fix_size(true, true), + } + } + return Ok(()); + } + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); + let inv = frame.inverse(); + for (id, world, w, h) in &nodes { + let local = inv * *world; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; + crate::cmd::props::restore_worlds(doc, &kept)?; + } + Ok(()) + })?; + self.tool.overlay.guides = guides; + Ok(()) + } + + /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything + /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and + /// text included, children by the same factor whatever their constraints. + fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { + let (frame, w0, h0) = r.frame; + let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; + let nodes = r.nodes.clone(); + let inv = frame.inverse(); + // A layer's place in the box scales about the pivot; a negative factor turns it over. + let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); + let sign = k.signum(); + self.preview(|doc, _| { + for (id, world, _, _) in &nodes { + let c = (inv * *world).as_coeffs(); + doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; + crate::cmd::arrange::scale_node(doc, *id, k.abs())?; + } + Ok(()) + })?; + let p = frame * pivot; + self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); + self.tool.overlay.guides = guides; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs new file mode 100644 index 000000000..36a12bc29 --- /dev/null +++ b/crates/engine/src/canvas/tools/draw.rs @@ -0,0 +1,231 @@ +use super::handles::SNAP_PX; +use super::pen::take_enclosed; +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<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let page = ds.page; + if ds.text_edit.is_some() { + textedit::end(self)?; + } + // Frames drawn inside frames nest; sections only at the top or in sections. + let parent = match self.tool.tool { + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } + _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), + }; + let targets = Targets::gather(&doc, page, &[]); + self.begin_interaction(self.tool.tool.label())?; + self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; + Ok(()) + } + + pub(super) fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { + let tool = self.tool.tool; + let mut p = ev.p(); + let mut guides = Vec::new(); + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { + let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let mut created = id; + let page = self.page()?; + self.preview(|doc, sel| { + let (mut a, mut b) = (start, p); + if pixel { + a = Point::new(a.x.round(), a.y.round()); + b = Point::new(b.x.round(), b.y.round()); + } + let pw = doc.world_transform(parent); + let (kind, base) = match tool { + ToolId::Frame => (NodeKind::Frame, "Frame"), + ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + ToolId::Slice => (NodeKind::Slice, "Slice"), + ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), + ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), + ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), + ToolId::Line => (NodeKind::Line, "Line"), + ToolId::Arrow => (NodeKind::Line, "Arrow"), + _ => return Ok(()), + }; + let name = match tool { + ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), + _ => base.to_string(), + }; + let nid = doc.alloc_id(); + let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; + if tool == ToolId::Arrow { + n.stroke.cap_end = StrokeCap::LineArrow; + } + if matches!(tool, ToolId::Line | ToolId::Arrow) { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + // Alt draws from the centre, as for the other shapes. + let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; + n.width = v.hypot(); + n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + } else { + let mut d = b - a; + if ev.shift { + let m = d.x.abs().max(d.y.abs()); + d = Vec2::new(m * d.x.signum(), m * d.y.signum()); + } + let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; + let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + n.width = r.width(); + n.height = r.height(); + n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); + } + doc.insert(n, parent, None)?; + *sel = vec![nid]; + created = Some(nid); + let _ = page; + Ok(()) + })?; + self.tool.overlay.guides = guides; + self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; + Ok(()) + } + + pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + self.tool.overlay.marquee = None; + self.tool.overlay.guides.clear(); + self.tool.overlay.scale = None; + self.tool.overlay.arc = None; + self.tool.overlay.shape = None; + self.tool.overlay.measure_box = None; + self.tool.overlay.insert_line = None; + self.tool.overlay.drop_cell = None; + let drop_target = self.tool.overlay.drop_target.take(); + match g { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + if moved { + // A free move lands in the frame under the pointer (or the page) where it was + // dropped; layers in an auto layout frame already sit in their slot. + let page = self.page()?; + let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; + let moving = if duplicate { sel } else { ids }; + let doc = self.doc()?.clone(); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let target = drop_target.unwrap_or(page); + let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); + if !in_al && current != Some(target) { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + let d = ds.doc_mut(); + for m in &moving { + // Group, boolean group and instance layers stay put: they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { + continue; + } + // A layer can't go into itself; it stays where it is. + let _ = d.reparent(*m, target, None); + } + crate::normalize(ds.doc_mut(), &before); + } + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. + if self.refused(&moving, true) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } + } + } else { + self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } + } + } + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { + // Layers inside an instance keep their main's size and rotation. + if self.refused(&self.selection(), false) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } + Gesture::Draw { start, id, parent, .. } => { + let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; + if tiny || id.is_none() { + // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). + self.cancel_interaction()?; + self.begin_interaction(self.tool.tool.label())?; + let (w, h) = self.tool.tool.click_size(); + let target = start + Vec2::new(w, h); + let mut fake = ev.clone(); + fake.x = target.x; + fake.y = target.y; + fake.shift = false; + fake.alt = false; + self.tool.gesture = Gesture::None; + let targets = Targets::default(); + self.draw_drag(&fake, start, None, parent, &targets)?; + self.tool.gesture = Gesture::None; + } + if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) + && let Some(drawn) = self.selection().first().copied() + { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + take_enclosed(ds.doc_mut(), drawn)?; + crate::normalize(ds.doc_mut(), &before); + } + self.commit_interaction()?; + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + Gesture::Pan { .. } => { + self.tool.overlay.cursor = Cursor::Grab; + if let Some(t) = self.tool.temp_from.take() { + self.tool.tool = t; + } + } + Gesture::Pencil { mut points, mut pressure } => { + self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } + self.pencil_finish(&points, &pressure)?; + } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} + Gesture::Vector(_) => self.commit_interaction()?, + } + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs new file mode 100644 index 000000000..7ccaca403 --- /dev/null +++ b/crates/engine/src/canvas/tools/handles.rs @@ -0,0 +1,426 @@ +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). +pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { + labels + .iter() + .rev() + .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) + .map(|(id, _)| *id) +} + +/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). +pub(super) fn in_quad(q: &[Point; 4], p: Point) -> bool { + let (mut left, mut right) = (false, false); + for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { + let side = (*b - *a).cross(p - *a); + if !side.is_finite() { + return false; + } + left |= side < 0.0; + right |= side > 0.0; + } + // On one side of every edge (a point can't be on all four edges of a quad with an area). + left != right +} + +/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, +/// 1 middle, 2 end). +pub fn anchor_of(name: &str) -> Option<(u8, u8)> { + ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) +} + +/// The name of anchor `(column, row)`. +pub fn anchor_name(a: (u8, u8)) -> &'static str { + ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") +} + +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + +const HANDLE_PX: f64 = 5.0; + +const ROTATE_PX: f64 = 18.0; + +/// Snap distance in screen pixels. +pub(crate) const SNAP_PX: f64 = 5.0; + +/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a +/// double-click made with an unsteady hand doesn't drag what it lands on. +pub(super) const DRAG_SLOP_PX: f64 = 3.0; + +/// Screen pixels from a path within which the Text on a path tool takes it. +pub(super) const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +pub(super) fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} + +/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). +pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { + let (f, w, h) = selection_frame(doc, sel)?; + Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) +} + +/// The selection's handle box: the page transform of its top-left corner (axes along the box) and +/// its size. One layer: its own box; several sharing a rotation: a box turned with them; +/// otherwise the page-aligned bounds. +pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { + let ids = roots(doc, sel); + if let [id] = ids.as_slice() { + let n = doc.node(*id)?; + return Some((doc.world_transform(*id), n.width, n.height)); + } + let Some(angle) = shared_rotation(doc, &ids) else { + let b = doc.bounds_of(&ids)?; + return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); + }; + let rot = Affine::rotate(angle); + let inv = rot.inverse(); + let b = ids + .iter() + .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) + .reduce(|a, b| a.union(b))?; + Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) +} + +/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the +/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. +fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { + use std::f64::consts::{FRAC_PI_2, PI}; + const EPS: f64 = 1e-4; + let mut angles = ids.iter().map(|i| { + let c = doc.world_transform(*i).as_coeffs(); + c[1].atan2(c[0]) + }); + let a = angles.next()?; + let same = |b: f64| { + let d = (b - a).rem_euclid(PI); + d.min(PI - d) < EPS + }; + let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; + (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) +} + +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +pub(super) fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. +pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { + return None; + } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } + let corners = selection_box(doc, sel)?; + // Lines (zero height) only get end handles. + let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); + handle_at_corners(&corners, p, zoom, is_line) +} + +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +pub(super) fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + +/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; +/// `is_line` keeps only the two end handles. +pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { + let tol = HANDLE_PX / zoom.max(1e-6); + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; + } + if !is_line { + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; + } + } + // Rotation zones: just outside each corner. + let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); + let rot = ROTATE_PX / zoom.max(1e-6); + for (i, c) in corners.iter().enumerate() { + let d = (*c - p).hypot(); + let outward = (p - *c).dot(*c - center) > 0.0; + if d <= rot && d > tol && outward { + return Some(Handle::Rotate(i as u8)); + } + } + None +} + +pub(super) fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { + selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) +} + +/// The cursor over handle `h` of the box with corners `c`. +pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { + let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); + match h { + Handle::Corner(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } + } + Handle::Edge(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + Cursor::Resize(angle(a, b) + 90.0) + } + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } + } +} + +/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). +pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} + } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, + }; + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); + } + (x0, y0, x1, y1) +} + +/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. +fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { + match h { + Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), + Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), + Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), + Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), + Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), + Handle::Edge(1) => Point::new(w0, h0 / 2.0), + Handle::Edge(2) => Point::new(w0 / 2.0, h0), + Handle::Edge(_) => Point::new(0.0, h0 / 2.0), + } +} + +/// Anchor `(column, row)` of a `w`×`h` box, in its axes. +fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { + Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) +} + +/// The Scale tool's anchor point on the selection's handle box, in page coordinates. +pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { + let (f, w, h) = selection_frame(doc, sel)?; + Some(f * anchor_in_box(anchor, w, h)) +} + +/// The handle across the box from `h`. +fn opposite(h: Handle) -> Handle { + match h { + Handle::Corner(i) => Handle::Corner((i + 2) % 4), + Handle::Edge(i) => Handle::Edge((i + 2) % 4), + Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), + } +} + +/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box +/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the +/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis +/// that moved the most; a negative factor flips. None when the box has no size to scale by. +pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { + const EPS: f64 = 1e-9; + if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { + return None; + } + let h = handle_point(handle, w0, h0); + let anchor_pt = anchor_in_box(anchor, w0, h0); + // The axes the handle moves along. + let (ax_x, ax_y) = match handle { + Handle::Edge(0) | Handle::Edge(2) => (false, true), + Handle::Edge(_) => (true, false), + _ => (true, true), + }; + let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); + let pivot = if from_center { + Point::new(w0 / 2.0, h0 / 2.0) + } else if on_side { + handle_point(opposite(handle), w0, h0) + } else { + anchor_pt + }; + let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); + let kx = ratio(ax_x, q.x, h.x, pivot.x); + let ky = ratio(ax_y, q.y, h.y, pivot.y); + let k = match (kx, ky) { + (Some(a), Some(b)) => { + if a.abs() > b.abs() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => return None, + }; + if !k.is_finite() { + return None; + } + // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. + Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) +} + +/// The readout of a shape handle drag on `id`. +pub(super) fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { + Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) +} + +#[allow(dead_code)] +fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +pub(super) fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs new file mode 100644 index 000000000..a369463c9 --- /dev/null +++ b/crates/engine/src/canvas/tools/keys.rs @@ -0,0 +1,174 @@ +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 + /// keys, Enter to finish the shape builder). Returns whether the key was used. + pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } + if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return crate::vectoredit::key(self, name, shift, alt, cmd); + } + if !alt && !cmd && self.guide_key(name, shift)? { + return Ok(true); + } + crate::shapebuilder::key(self, name) + } + + /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big + /// nudge), Escape deselects it. + fn guide_key(&mut self, name: &str, shift: bool) -> Result { + let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; + // Backspace is Delete, `Left` is `ArrowLeft`, and so on. + let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); + let page = self.page()?; + let at = |mut p: serde_json::Value| { + if owner != page { + p["frame"] = json!(owner); + } + p + }; + let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; + let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; + let d = match (name, g.vertical) { + ("Delete", _) => { + self.execute("guide.remove", &at(json!({"index": index})))?; + return Ok(true); + } + ("Escape", _) => { + self.execute("guide.select", &json!({}))?; + return Ok(true); + } + ("ArrowLeft", true) | ("ArrowUp", false) => -step, + ("ArrowRight", true) | ("ArrowDown", false) => step, + _ => return Ok(false), + }; + self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; + Ok(true) + } + + /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in + /// outline mode shapes only on their outlines. + pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let focus = self.ds().ok().and_then(crate::DocState::focused); + // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); + } + match focus { + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), + } + } + + /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` + /// (default the middle of the view) and selected. + pub fn place(&mut self, at: Option) -> Result<()> { + let tool = self.tool.tool; + let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); + if !tool.draws_shape() { + return refuse("choose a shape or frame tool first"); + } + if self.tool.busy() { + return refuse("finish the drag first"); + } + let v = &self.ds()?.view; + let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); + let (w, h) = tool.click_size(); + let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); + let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); + if r.is_err() { + self.tool.gesture = Gesture::None; + let _ = self.cancel_interaction(); + } + r + } + + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the + /// focus view, once the focused frame or nothing is selected, it ends the focus view). + pub fn escape(&mut self) -> Result<()> { + // Following someone in a live session: Escape stops that first. + if self.live_unfollow() { + return Ok(()); + } + if self.tool.pen.is_some() { + return self.pen_end(); + } + if self.tool.busy() { + self.tool.gesture = Gesture::None; + self.tool.overlay = Overlay::default(); + return self.cancel_interaction(); + } + if self.end_origin_edit() { + return Ok(()); + } + if self.ds()?.text_edit.is_some() { + return textedit::end(self); + } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::escape(self); + } + if self.tool.tool != ToolId::Move { + self.set_tool(ToolId::Move); + return Ok(()); + } + let sel = self.selection(); + if let Some(f) = self.ds()?.focused() + && (sel.is_empty() || sel == [f]) + { + self.ds_mut()?.focus = None; + return Ok(()); + } + // Escape selects the parent, then nothing. + let doc = self.doc()?; + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + if !parents.is_empty() { + let mut uniq = Vec::new(); + for p in parents { + if !uniq.contains(&p) { + uniq.push(p); + } + } + self.set_selection(uniq); + } else { + self.set_selection(vec![]); + } + Ok(()) + } + + /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor + /// in vector or text edit mode). + pub(super) fn arc_handles_shown(&self) -> bool { + let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); + self.tool.tool == ToolId::Move + && !self.tool.dev_mode + && (arc_drag || !self.tool.busy()) + && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) + } + + /// The selected ellipse's arc handles as the canvas shows them at the current zoom. + pub fn arc_handles(&self) -> Option { + let ds = self.ds().ok()?; + self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() + } + + /// The selected shape's corner radius and star ratio handles as the canvas shows them now. + pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { + let Ok(ds) = self.ds() else { return vec![] }; + if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } + } + + /// Snapshot of tool state for the UI / inspect. + pub fn tool_info(&self) -> serde_json::Value { + let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); + json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) + } +} diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs new file mode 100644 index 000000000..4857e83c1 --- /dev/null +++ b/crates/engine/src/canvas/tools/pen.rs @@ -0,0 +1,279 @@ +use super::handles::{PENCIL_STROKE, selected_end_at}; +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 +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) +} + +/// A frame or section drawn around layers takes those of its siblings that lie fully inside it +/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, +/// where the new frame is one more item of the flow. +pub(super) fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { + let Some(n) = doc.node(drawn) else { return Ok(()) }; + let section = matches!(n.kind, NodeKind::Section { .. }); + let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; + if parent.layout.is_some() { + return Ok(()); + } + let inside: Vec = parent + .children + .iter() + .copied() + .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) + .collect(); + for c in inside { + doc.reparent(c, drawn, None)?; + } + Ok(()) +} + +impl Session { + /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first + /// point) counts among the targets. + pub(super) fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { + let (id, extra) = match &self.tool.pen { + Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), + Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), + None => (None, Vec::new()), + }; + self.snap_pen(ev, id, &extra) + } + + pub(super) fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { + self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; + let (doc, page) = { + let ds = self.ds()?; + (ds.doc.clone(), ds.page) + }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + return self.pen_edit(id, Some(v)); + } + let pen = self.tool.pen.get_or_insert_with(PenPath::default); + // Clicking the first anchor closes the path. + if pen.points.len() > 2 + && let Some((first, _, _)) = pen.points.first() + && (*first - p).hypot() * ev.zoom < 8.0 + { + pen.closed = true; + self.pen_update()?; + return self.pen_finish(); + } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } + if pen.parent.is_none() { + pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); + } + let mut p = p; + if ev.shift + && let Some((last, _, _)) = pen.points.last() + { + p = at_45_degrees(*last, p); + } + pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + self.tool.gesture = Gesture::PenDrag { anchor: p }; + self.pen_update() + } + + /// Rebuild the pen's vector node from its anchors. + pub(super) fn pen_update(&mut self) -> Result<()> { + let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; + self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); + if pen.points.len() < 2 { + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + let page = self.page()?; + let parent = pen.parent.unwrap_or(page); + if pen.id.is_none() { + self.begin_interaction("Pen")?; + } + let ds = self.ds_mut()?; + let doc = ds.doc_mut(); + let pw = doc.world_transform(parent); + let inv = pw.inverse(); + // Anchors and handles into the parent's space. + let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); + let mut path = figcraft_geom::BezPath::new(); + for (k, (p, _, _)) in pts.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { + path.curve_to(prev.2, cur.1, *p); + } + } + if pen.closed + && let (Some(last), Some(first)) = (pts.last(), pts.first()) + { + path.curve_to(last.2, first.1, first.0); + path.close_path(); + } + use figcraft_geom::Shape; + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + // Straight segments stay straight. + for s in &mut net.segments { + if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { + s.ta = (0.0, 0.0); + s.tb = (0.0, 0.0); + } + } + if !pen.closed { + net.regions.clear(); + } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } + let id = match pen.id { + Some(id) if doc.contains(id) => id, + _ => { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); + n.fills.clear(); + n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + n.stroke.align = StrokeAlign::Center; + doc.insert(n, parent, None)?; + id + } + }; + let n = doc.get_mut(id)?; + n.kind = NodeKind::Vector { network: net }; + n.width = bb.width(); + n.height = bb.height(); + n.transform = Affine::translate((bb.x0, bb.y0)); + if pen.closed && n.fills.is_empty() { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + } + ds.selection = vec![id]; + ds.revision += 1; + if let Some(p) = self.tool.pen.as_mut() { + p.id = Some(id); + } + Ok(()) + } + + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). + pub fn pen_finish(&mut self) -> Result<()> { + let pen = self.tool.pen.take(); + self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + self.tool.gesture = Gesture::None; + if pen.as_ref().is_some_and(|p| p.id.is_some()) { + self.commit_interaction()?; + } + Ok(()) + } + + pub(super) fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { + if points.len() < 2 { + return Ok(()); + } + // Smooth: fit a curve through the samples. + let mut poly = figcraft_geom::BezPath::new(); + for (k, p) in points.iter().enumerate() { + if k == 0 { + poly.move_to(*p); + } else { + poly.line_to(*p); + } + } + let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); + let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } + let page = self.page()?; + let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); + let pw = self.doc()?.world_transform(parent); + let mut local = fitted.clone(); + local.apply_affine(pw.inverse()); + let _ = d; + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } + self.execute( + "node.create", + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), + )?; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs new file mode 100644 index 000000000..550c4a66e --- /dev/null +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -0,0 +1,295 @@ +use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; +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 + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + + pub fn set_tool(&mut self, t: ToolId) { + if self.tool.tool == ToolId::Pen && t != ToolId::Pen { + let _ = self.pen_finish(); + } + if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + let _ = crate::vectoredit::end(self); + } + self.tool.tool = t; + self.tool.gesture = Gesture::None; + self.tool.pick_only = false; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; + self.tool.shape_builder = None; + if t == ToolId::ShapeBuilder { + // Regions show straight away, before the pointer moves. + let sel = self.selection(); + let _ = crate::shapebuilder::arrangement(self, &sel); + } + if let Ok(ds) = self.ds_mut() { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { + ds.text_edit = None; + } + ds.revision += 1; + } + } + + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + + /// Feed one pointer event to the current tool. + pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { + return Ok(()); + } + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + return Ok(()); + } + if tool == ToolId::ShapeBuilder { + return self.shape_builder_pointer(ev); + } + match ev.kind { + PointerKind::Hover => self.hover(ev), + PointerKind::Down => match tool { + ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } + // A click on the canvas deselects a ruler guide (the UI selects guides itself). + self.ds_mut()?.guide = None; + self.move_down(ev) + } + ToolId::Hand => { + let v = &self.ds()?.view; + self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; + self.tool.overlay.cursor = Cursor::Grabbing; + Ok(()) + } + ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), + ToolId::Pen => self.pen_down(ev), + ToolId::Pencil => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Comment => { + self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); + Ok(()) + } + ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), + t if t.draws_shape() => self.draw_down(ev), + _ => Ok(()), + }, + PointerKind::Move => self.drag(ev), + PointerKind::Up => self.up(ev), + PointerKind::DoubleClick => self.double_click(ev), + } + } + + /// The rotation origin handle while it shows: a press drags it, a double-click puts it back + /// at the centre. True when the event was the handle's. + fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { + let dragging = matches!(self.tool.gesture, Gesture::Origin); + if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { + return Ok(false); + } + match ev.kind { + PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, + PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, + PointerKind::Up if dragging => self.tool.gesture = Gesture::None, + PointerKind::DoubleClick => self.set_rotation_origin(None)?, + PointerKind::Hover => {} + _ => return Ok(false), + } + self.tool.overlay.cursor = Cursor::Move; + Ok(true) + } + + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { + let tool = self.acting_tool(); + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } + let mut hover = None; + let mut guides = Vec::new(); + let mut measure_box = None; + let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + self.tool.overlay.arc_hover = arc_hover; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; + self.tool.overlay.shape_hover = shape_hover; + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { + cursor = resize_cursor(&doc, &sel, h); + } else { + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } + if ev.alt || self.tool.dev_mode { + use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; + // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the + // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. + let around: Vec = sel.iter().copied().chain(hover).collect(); + let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { + if sel.is_empty() { + return None; + } + let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); + guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) + }); + if let Some(what) = what { + let (g, b) = hover_lines(&doc, page, &sel, what); + guides = g; + measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); + } + } + } + } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } + self.tool.overlay.measure_box = measure_box; + self.tool.overlay.cursor = cursor; + if tool == ToolId::Pen { + // Where a click would land (snapped), with its guides. + let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; + guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; + self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); + } + } + self.tool.overlay.guides = guides; + let ds = self.ds_mut()?; + if ds.hover != hover { + ds.hover = hover; + ds.revision += 1; + } + Ok(()) + } + + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + pub(super) fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } +} diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs similarity index 99% rename from crates/engine/src/vectoredit.rs rename to crates/engine/src/canvas/vectoredit.rs index 03b22d224..fc263b4f2 100644 --- a/crates/engine/src/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/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs similarity index 99% rename from crates/engine/src/vectoredit/ops.rs rename to crates/engine/src/canvas/vectoredit/ops.rs index 935f033af..8832e05dd 100644 --- a/crates/engine/src/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/vectoredit/point_snap.rs b/crates/engine/src/canvas/vectoredit/point_snap.rs similarity index 100% rename from crates/engine/src/vectoredit/point_snap.rs rename to crates/engine/src/canvas/vectoredit/point_snap.rs diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs similarity index 98% rename from crates/engine/src/vectoredit/pointer.rs rename to crates/engine/src/canvas/vectoredit/pointer.rs index d977ce877..ebd8e2e33 100644 --- a/crates/engine/src/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/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs similarity index 100% rename from crates/engine/src/vectoredit/regions.rs rename to crates/engine/src/canvas/vectoredit/regions.rs diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/regions_tests.rs rename to crates/engine/src/canvas/vectoredit/regions_tests.rs diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs similarity index 100% rename from crates/engine/src/vectoredit/tests.rs rename to crates/engine/src/canvas/vectoredit/tests.rs diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs similarity index 99% rename from crates/engine/src/vectoredit/transform.rs rename to crates/engine/src/canvas/vectoredit/transform.rs index 5efb53433..0f87a0bd5 100644 --- a/crates/engine/src/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/vectoredit/transform_tests.rs b/crates/engine/src/canvas/vectoredit/transform_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/transform_tests.rs rename to crates/engine/src/canvas/vectoredit/transform_tests.rs diff --git a/crates/engine/src/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs similarity index 99% rename from crates/engine/src/vectoredit/width.rs rename to crates/engine/src/canvas/vectoredit/width.rs index ba51b043a..13c7be0c3 100644 --- a/crates/engine/src/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/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs similarity index 99% rename from crates/engine/src/vectoredit/width_sides_tests.rs rename to crates/engine/src/canvas/vectoredit/width_sides_tests.rs index bf2dbeb0b..937245ae6 100644 --- a/crates/engine/src/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/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs similarity index 99% rename from crates/engine/src/vectoredit/width_tests.rs rename to crates/engine/src/canvas/vectoredit/width_tests.rs index 54b4b0e28..8c89d4c69 100644 --- a/crates/engine/src/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/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 8f8e40d25..19572c959 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 { @@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result> { pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) }; let sub = doc.subtree(id); + // The layers below `id` scale whatever their constraints, so where they are layers of an + // instance their new size and place are overrides. The edit's own diff takes geometry that + // changed with an ancestor's size for what constraints give, so the next rebuild of the + // instance would put them back at their main's size (#922). + let in_instance: Vec = sub.iter().enumerate().map(|(depth, i)| depth > 0 && crate::components::inside_instance(doc, *i)).collect(); let tiles = |paints: &mut Vec| { for p in paints { if let figcraft_color::PaintKind::Image(img) = &mut p.kind @@ -416,6 +421,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { }; for (depth, i) in sub.iter().enumerate() { let n = doc.get_mut(*i)?; + let (w0, h0, t0) = (n.width, n.height, n.transform); tiles(&mut n.fills); tiles(&mut n.strokes); let cl = &mut n.child_layout; @@ -511,6 +517,14 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { let c = n.transform.as_coeffs(); n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]); } + if in_instance.get(depth).copied().unwrap_or(false) { + if n.width != w0 || n.height != h0 { + n.overrides.insert("size".into()); + } + if n.transform != t0 { + n.overrides.insert("position".into()); + } + } } Ok(()) } diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index d1d360f46..8cff0c42c 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -4,11 +4,11 @@ //! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only //! a message's author edits or deletes it, and only the thread's author deletes the thread. -use figcraft_doc::*; +use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 428738d11..0f59f41e4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -2,15 +2,31 @@ 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; +mod props; +mod variants; + +use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, reset_override, reset_slot, restore_main, slot_insert, swap}; +pub(crate) use instance::{swap_at, swap_instance}; +use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props}; +pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props}; +use variants::{add_variant, combine}; +pub use variants::{can_add_variant, can_combine_variants}; + /// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and /// sets (those `ids` are or are in), put on or taken off. fn hide_when_publishing(s: &mut Session, p: &Value) -> Result { @@ -343,1185 +359,3 @@ fn set_name_from(variant: &str) -> Option { let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) } - -/// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; - if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); - } - let bounds = doc.bounds_of(ids).unwrap_or_default(); - let pw = doc.world_transform(parent); - let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); - let set = doc.alloc_id(); - let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); - n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); - n.fills.clear(); - doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; - for i in ids { - doc.reparent(*i, set, None)?; - } - Ok(set) -} - -const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; - -/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they -/// can: anything but main components, components that are variants already (combining them would -/// put a set in a set), library copies, components inside an instance, and a component inside -/// another one of `ids`. -fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { - if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - Some("select components only") - } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { - Some("components that are variants already can't be combined again") - } else if ids.iter().any(|i| doc.is_remote(*i)) { - Some(crate::library::READ_ONLY) - } else if ids.iter().any(|i| inside_instance(doc, *i)) { - Some(INSIDE_INSTANCE) - } else if roots(doc, ids).len() != ids.len() { - Some("a component can't be combined with one inside it") - } else { - None - } -} - -/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more -/// main components that [`combine_refusal`] lets through. -pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { - ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() -} - -/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main -/// component not in a set yet (it becomes one); never a library copy or inside an instance. -pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { - let Some(n) = doc.node(id) else { return false }; - let local = !doc.is_remote(id) && !inside_instance(doc, id); - match n.kind { - NodeKind::ComponentSet { .. } => { - local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) - } - NodeKind::Component { .. } => local, - _ => false, - } -} - -/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", -/// "Property 1=VariantN" unless already in prop=value form. Returns the set. -fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let set = wrap_in_set(cmd, doc, ids)?; - for (k, i) in ids.iter().enumerate() { - let node = doc.get_mut(*i)?; - if !node.name.contains('=') { - node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); - } - } - Ok(set) -} - -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let mut seen = BTreeSet::new(); - let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); - let doc = s.doc_mut()?; - if let Some(why) = combine_refusal(doc, &ids) { - return Err(EngineError::Disabled(cmd.into(), why.into())); - } - let ids = z_sorted(doc, &ids); - let set = make_variant_set(cmd, doc, &ids)?; - s.set_selection(vec![set]); - Ok(json!({"id": set})) -} - -/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). -fn fit_set(doc: &mut Document, set: NodeId) { - let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; - let (mut w, mut h) = (0.0f64, 0.0f64); - for c in kids { - if let Some(n) = doc.node(c) { - let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); - w = w.max(b.x1 + 20.0); - h = h.max(b.y1 + 20.0); - } - } - if let Some(n) = doc.node_mut(set) { - n.width = n.width.max(w); - n.height = n.height.max(h); - } -} - -fn add_variant(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; - let doc = s.doc_mut()?; - let (set, base) = match doc.get(t)?.kind { - NodeKind::ComponentSet { .. } => { - let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; - (t, last) - } - NodeKind::Component { .. } => match variant_set(doc, t) { - Some(set) => (set, t), - // A main component on its own becomes a set of itself, then gains the new variant. - None => (make_variant_set(cmd, doc, &[t])?, t), - }, - _ => return Err(bad(cmd, "select a component set or component")), - }; - let options = variant_options(doc, set); - let mut pairs = variant_props(&doc.get(base)?.name); - let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); - let existing: BTreeSet> = - doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); - match (string(p, "property"), string(p, "value")) { - (Some(prop), Some(value)) => { - let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); - if !options.iter().any(|(k, _)| *k == prop) { - return Err(bad(cmd, format!("unknown variant property `{prop}`"))); - } - if value.is_empty() || value.contains([',', '=']) { - return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); - } - pairs.insert(prop, value); - if existing.contains(&pairs) { - return Err(bad(cmd, "that variant already exists")); - } - } - (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), - _ => { - let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); - let mut k = existing.len() + 1; - loop { - pairs.insert(prop.clone(), format!("Variant{k}")); - if !existing.contains(&pairs) || k > 10_000 { - break; - } - k += 1; - } - } - } - let name = variant_name(&pairs, &order); - let bottom = doc - .get(set)? - .children - .iter() - .filter_map(|c| doc.node(*c)) - .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) - .fold(0.0f64, f64::max); - let dup = doc.duplicate_into(base, set, None)?; - let n = doc.get_mut(dup)?; - n.name = name; - let c = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); - fit_set(doc, set); - s.set_selection(vec![dup]); - Ok(json!({"id": dup})) -} - -fn detach(s: &mut Session, p: &Value) -> Result { - let cmd = "component.detach"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for i in ids { - if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { - continue; - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); - } - // Own layers lose their link (slot content stays as it is); nested instances stay - // instances of their own mains. - let nested = unlink_layers(doc, i, Some(i)); - keep_main_modes(doc, i)?; - let n = doc.get_mut(i)?; - n.kind = NodeKind::Frame; - n.source = None; - n.overrides.clear(); - n.prop_refs.remove("mainComponent"); - // No properties left to drive. - n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - for nn in nested { - rebase_instance(doc, nn); - } - out.push(i); - } - if out.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -/// Make the variable modes instance `i` takes from its main its own (it's being detached). -fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { - let modes = match doc.get(i)?.kind { - NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), - _ => return Ok(()), - }; - let own = &mut doc.get_mut(i)?.var_modes; - for (c, m) in modes { - own.entry(c).or_insert(m); - } - Ok(()) -} - -fn reset_instance(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetInstance"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut done = Vec::new(); - for i in ids { - let Some(inst) = owning_instance(doc, i) else { continue }; - let top = !inside_instance(doc, inst); - for d in doc.subtree(inst) { - let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; - if let Some(n) = doc.node(d) - && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) - && let Some(n) = doc.node_mut(d) - { - n.overrides.retain(|g| keep.contains(&g.as_str())); - } - } - // Property values go back to the defaults. - if top - && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind - { - *props = defaults; - } - // ...and stop following variables. - if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { - doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - } - done.push(inst); - } - if done.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - Ok(json!({"ids": done})) -} - -fn reset_override(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetOverride"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; - let group = string(p, "group"); - if let Some(g) = &group - && !OVERRIDE_GROUPS.contains(&g.as_str()) - { - return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); - } - let doc = s.doc_mut()?; - if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { - return Err(bad(cmd, "not a layer of an instance")); - } - let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); - let had = doc.get(i)?.overrides.clone(); - let n = doc.get_mut(i)?; - match &group { - Some(g) => { - n.overrides.remove(g); - } - None => n.overrides.clear(), - } - let mut changed = had != doc.get(i)?.overrides; - // The instance's own property values reset with `props`. - if top_root - && group.as_deref().is_none_or(|g| g == "props") - && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind - { - *props = defaults; - changed = true; - } - Ok(json!({"changed": changed})) -} - -/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. -fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { - let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); - let base = if base.is_empty() { "Slot".to_string() } else { base }; - let taken = |n: &str| { - owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) - || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) - }; - (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) -} - -/// The `preferred` components of a slot property: component or component set ids. -fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { - let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; - let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; - let mut out = Vec::new(); - for x in list.iter().take(500) { - let c = id_of(x) - .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) - .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; - if !out.contains(&c) { - out.push(c); - } - } - Ok(out) -} - -fn make_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.makeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; - let doc = s.doc()?; - let n = doc.get(id)?; - let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); - if !matches!(n.kind, NodeKind::Frame) || !inside_main { - return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); - } - if is_slot(doc, id) { - return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); - } - let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; - let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); - let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) -} - -fn remove_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.removeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; - let doc = s.doc()?; - if owning_instance(doc, id).is_some() { - return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); - } - let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; - delete_prop(s, &json!({"id": id, "name": name}))?; - Ok(json!({"id": id, "name": name})) -} - -/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. -fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { - let slot = match name { - Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, - None => id, - }; - if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { - return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); - } - Ok(slot) -} - -fn slot_insert(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.slotInsert"; - let c = req_id(cmd, p, "component")?; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let index = match get(p, "index").filter(|v| !v.is_null()) { - None => None, - Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), - }; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - if boolean(p, "replace").unwrap_or(false) { - for k in doc.get(slot)?.children.clone() { - doc.remove(k)?; - } - } - let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; - s.set_selection(vec![i]); - Ok(json!({"id": i, "slot": slot})) -} - -fn reset_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.resetSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; - let changed = slot_edited(doc, slot); - if changed { - doc.get_mut(slot)?.overrides.remove(SLOT); - } - Ok(json!({"id": slot, "changed": changed})) -} - -fn push_changes(s: &mut Session, p: &Value) -> Result { - let cmd = "component.pushChanges"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - // Changes go to the outer instance's main (never into a nested instance's own main). - let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; - if !doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); - } - // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, - // property values and slot content stay with the instance). - let mut pushed = 0; - for d in doc.subtree(inst) { - let Some(n) = doc.node(d).cloned() else { continue }; - let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; - if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { - continue; - } - let Some(mut target) = doc.node(src).cloned() else { continue }; - let groups: BTreeSet = n - .overrides - .iter() - .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) - .cloned() - .collect(); - for g in &groups { - copy_group(&n, &mut target, g); - } - if groups.is_empty() { - continue; - } - pushed += 1; - if let Some(slot) = doc.node_mut(src) { - *slot = target; - } - if let Some(m) = doc.node_mut(d) { - m.overrides.retain(|g| !groups.contains(g)); - } - } - Ok(json!({"layers": pushed})) -} - -fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { - let mut defs = BTreeMap::new(); - for (k, v) in props { - let def = match v { - Value::Bool(b) => PropDef::Boolean { default: *b }, - Value::String(t) => PropDef::Text { default: t.clone() }, - other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } - defs -} - -fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { - let mut map: BTreeMap = BTreeMap::new(); - let mut used: BTreeSet = BTreeSet::new(); - map.insert(i, component); - used.insert(component); - for d in sub.iter().filter(|d| **d != i) { - let src = doc.node(*d).and_then(|n| n.source); - let nid = match src { - Some(s) if !doc.contains(s) && used.insert(s) => s, - _ => doc.alloc_id(), - }; - map.insert(*d, nid); - } - map -} - -fn restore_main(s: &mut Session, p: &Value) -> Result { - let cmd = "component.restoreMain"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, "select an instance")); - }; - if doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); - } - let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; - let index = doc.index_in_parent(i); - // The main comes back with its old id (other instances point to it) and its layers with the - // ids their copies mirror, so every instance re-links by source. - let sub = doc.subtree(i); - let map = restore_id_map(doc, i, component, &sub); - let mut nodes = Vec::new(); - let mut nested = Vec::new(); - for d in &sub { - let Some(mut n) = doc.node(*d).cloned() else { continue }; - if *d == i { - let defs = restore_prop_defs(doc, &props); - n.kind = NodeKind::Component { description: String::new(), props: defs }; - n.source = None; - n.overrides.clear(); - n.prop_refs.clear(); - } else { - let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); - if parent_inst != Some(i) { - nodes.push(n); - continue; - } - if matches!(n.kind, NodeKind::Instance { .. }) { - nested.push(*d); - } else { - n.source = None; - n.overrides.clear(); - } - } - nodes.push(n); - } - doc.remove(i)?; - doc.paste_nodes(&nodes, &map, i, parent, index)?; - for nn in nested { - if let Some(m) = map.get(&nn).copied() { - rebase_instance(doc, m); - } - } - s.set_selection(vec![component]); - Ok(json!({"id": component})) -} - -fn swap(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swap"; - let c = req_id(cmd, p, "component")?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - for i in ids { - swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; - } - ok() -} - -/// Make instance `i` show main component `c`, keeping the values of properties with the same names -/// (the layers' overrides follow by name when the instance is rebuilt). -pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { - if doc.is_ancestor(c, i) { - return Err("an instance can't show the component it is inside".into()); - } - let defaults = doc.node(c).map(default_props).unwrap_or_default(); - match doc.get_mut(i).map(|n| &mut n.kind) { - Ok(NodeKind::Instance { component, props }) => { - *component = c; - let old = std::mem::take(props); - *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); - Ok(()) - } - _ => Err(format!("{i} is not an instance")), - } -} - -/// Swap the instance under a page point to a component (`instance.swapAt`). -pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swapAt"; - let c = req_id(cmd, p, "component")?; - let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let page = s.page()?; - let doc = s.doc()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - let family = crate::drop::swap_family(doc, c); - let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; - keep_text_overrides(s.doc_mut()?, target); - swap(s, &json!({"ids": [target], "component": c}))?; - s.set_selection(vec![target]); - Ok(json!({"id": target, "component": c})) -} - -/// 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). -pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { - return Err(format!("{id} is not an instance (component properties are bound on instances)")); - }; - if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { - return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; - } - match (prop_defs(doc, *component).get(name), kind) { - (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), - (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), - (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), - (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), - (None, _) => Err(format!("the instance has no property named `{name}`")), - } -} - -/// Write bound variable values into instance `id`'s boolean and text property values, and switch -/// it to the variant its bound variant properties name (only what changed; the instance then -/// follows like after `instance.setProperties`). A value that names no variant changes nothing. -pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; - let component = *component; - if let Some(set) = variant_set(doc, component) { - let options = variant_options(doc, set); - let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut wanted = current.clone(); - for (name, val) in sets { - if let VariableValue::String(t) = val - && options.iter().any(|(o, vals)| o == name && vals.contains(t)) - { - wanted.insert(name.clone(), t.clone()); - } - } - if wanted != current - && let Some(target) = find_variant(doc, set, &wanted) - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { component: c, .. } = &mut n.kind - { - *c = target; - } - } - let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; - let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; - let mut next = props.clone(); - for (name, val) in sets { - let v = match (defs.get(name), val) { - (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), - (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), - _ => continue, - }; - next.insert(name.clone(), v); - } - if next != *props - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { props, .. } = &mut n.kind - { - *props = next; - } -} - -/// Coerce a property value to the definition's type. -fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { - match def { - PropDef::Boolean { .. } => match v { - Value::Bool(b) => Ok(json!(b)), - Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), - _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), - }, - PropDef::Text { .. } => match v { - Value::String(t) => Ok(json!(t)), - Value::Number(n) => Ok(json!(n.to_string())), - _ => Err(bad(cmd, format!("`{name}` is a text property"))), - }, - PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { - Some(c) => Ok(json!(c.0)), - None => Err(bad(cmd, format!("`{name}` takes a component id"))), - }, - PropDef::Variant { options, .. } => { - let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } - } - PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), - } -} - -fn set_props(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.setProperties"; - let ids = req_targets(cmd, s, p)?; - let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; - let doc = s.doc_mut()?; - for i in ids { - let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, format!("{i} is not an instance"))); - }; - // Variant properties: switch to the matching sibling variant. - let set = variant_set(doc, component); - let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); - let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut target = component; - let mut rest = Vec::new(); - for (k, v) in &props { - if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { - let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if !vals.contains(&val) { - return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); - } - wanted.insert(k.clone(), val); - } else { - rest.push((k.clone(), v.clone())); - } - } - if let Some(set) = set - && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() - { - target = find_variant(doc, set, &wanted).unwrap_or(component); - } - let defs = match doc.node(target).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - }; - let mut values = Vec::new(); - for (k, v) in rest { - let def = defs.get(&k).ok_or_else(|| { - let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); - bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) - })?; - values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); - } - if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { - *c = target; - for (k, v) in values { - ip.insert(k, v); - } - for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { - ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); - } - } - } - ok() -} - -/// Where component properties live: a set (its variants share them) or a lone component. -enum Owner { - Set(NodeId), - Comp(NodeId), -} - -impl Owner { - fn resolve(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - match n.kind { - NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), - _ => { - let c = owning_component(doc, id)?; - Some(match variant_set(doc, c) { - Some(s) => Owner::Set(s), - None => Owner::Comp(c), - }) - } - } - } - - fn components(&self, doc: &Document) -> Vec { - match self { - Owner::Comp(c) => vec![*c], - Owner::Set(s) => doc - .node(*s) - .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) - .unwrap_or_default(), - } - } - - fn id(&self) -> NodeId { - match self { - Owner::Set(s) | Owner::Comp(s) => *s, - } - } -} - -fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { - match doc.node(c).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - } -} - -fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { - if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { - f(props); - } - Ok(()) -} - -fn valid_prop_name(cmd: &str, name: &str) -> Result { - let name = name.trim(); - if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { - return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); - } - Ok(name.to_string()) -} - -fn add_prop(s: &mut Session, p: &Value) -> Result { - add_prop_for("component.addProperty", s, p) -} - -/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its -/// slot property here). -fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; - let t = req_string(cmd, p, "type")?; - let doc = s.doc_mut()?; - if inside_instance(doc, target) { - return Err(bad(cmd, "select a component, not a layer of an instance")); - } - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; - // A layer selected inside the component gets bound to the new property. - let layer = id(p, "layer") - .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); - let comps = owner.components(doc); - let variants = match &owner { - Owner::Set(s) => variant_options(doc, *s), - Owner::Comp(_) => Vec::new(), - }; - if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { - return Err(bad(cmd, format!("a property named `{name}` already exists"))); - } - let layer_node = layer.and_then(|l| doc.node(l)).cloned(); - let def = match t.to_ascii_lowercase().as_str() { - // Without a default, a bound layer keeps showing as it does: its visibility is the default. - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, - "text" => PropDef::Text { - default: string(p, "default") - .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) - .unwrap_or_else(|| name.clone()), - }, - "instanceswap" | "instance_swap" | "instance" => { - let d = id(p, "default") - .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { - Some(NodeKind::Instance { component, .. }) => Some(*component), - _ => None, - }) - .and_then(|c| main_of(doc, c)) - .ok_or_else(|| bad(cmd, "default: a component id"))?; - PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } - } - "variant" => { - let dv = string(p, "default") - .map(|d| d.trim().to_string()) - .filter(|d| !d.is_empty() && !d.contains([',', '='])) - .unwrap_or_else(|| "Default".into()); - let set = match owner { - Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, - }; - for c in Owner::Set(set).components(doc) { - let cn = doc.get_mut(c)?; - cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; - } - s.set_selection(vec![set]); - return Ok(json!({"id": set})); - } - "slot" => { - if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { - return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); - } - PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } - } - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), - }; - for c in &comps { - set_prop_defs(doc, *c, |m| { - m.insert(name.clone(), def.clone()); - })?; - // Existing instances get the default. - if !takes_value(&def) { - continue; - } - for inst in instances_of(doc, *c) { - if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { - props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); - } - } - } - if let Some(l) = layer { - bind(cmd, doc, l, Some(&name), None)?; - } - Ok(json!({"id": owner.id(), "name": name})) -} - -/// Bind (or with `property: None` unbind) a layer's field to a property of its component. -fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { - let n = doc.get(layer)?.clone(); - if inside_instance(doc, layer) { - return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); - } - let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; - let Some(property) = property.filter(|p| !p.is_empty()) else { - let n = doc.get_mut(layer)?; - match field { - Some(f) => { - n.prop_refs.remove(f); - } - None => n.prop_refs.clear(), - } - return Ok(()); - }; - let defs = prop_defs(doc, comp); - let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; - let expected = match def { - PropDef::Boolean { .. } => "visible", - PropDef::Text { .. } => "characters", - PropDef::InstanceSwap { .. } => "mainComponent", - PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), - PropDef::Slot { .. } => SLOT, - }; - let field = field.unwrap_or(expected); - if field != expected { - return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); - } - match field { - "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), - "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), - "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), - SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), - SLOT if doc - .subtree(comp) - .into_iter() - .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => - { - return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); - } - _ => {} - } - let n = doc.get_mut(layer)?; - n.prop_refs.insert(field.to_string(), property.to_string()); - // On the main, a boolean property's value is the visibility of the layers it controls. - if let PropDef::Boolean { default } = def { - n.visible = *default; - } - Ok(()) -} - -fn bind_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.bindProperty"; - let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; - let property = string(p, "property"); - let field = string(p, "field"); - if let Some(f) = &field - && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) - { - return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); - } - let doc = s.doc_mut()?; - bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; - ok() -} - -/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. -fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { - for d in doc.subtree(comp) { - let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); - if refs.values().any(|v| v == old) - && let Some(n) = doc.node_mut(d) - { - match new { - Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), - None => n.prop_refs.retain(|_, v| v != old), - } - } - } - // Instances' values and the variables bound to them follow (a deleted property drops both). - let old_key = format!("{PROP_BINDING}{old}"); - for inst in instances_of(doc, comp) { - let n = doc.get_mut(inst)?; - if let NodeKind::Instance { props, .. } = &mut n.kind - && let Some(v) = props.remove(old) - && let Some(nw) = new - { - props.insert(nw.to_string(), v); - } - if let Some(var) = n.bound.remove(&old_key) - && let Some(nw) = new - { - n.bound.insert(format!("{PROP_BINDING}{nw}"), var); - } - } - Ok(()) -} - -fn edit_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.editProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Some(nn) = &new_name { - let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) - || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); - if taken { - return Err(bad(cmd, format!("a property named `{nn}` already exists"))); - } - } - // Variant property of a set. - if let Owner::Set(set) = owner - && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - let rename_value = match (string(p, "value"), string(p, "newValue")) { - (Some(v), Some(nv)) => { - let nv = nv.trim().to_string(); - if !values.contains(&v) { - return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); - } - if nv.is_empty() || nv.contains([',', '=']) { - return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); - } - Some((v, nv)) - } - _ => None, - }; - let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); - // Reordered: every variant's name lists the properties in the new order. - if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { - let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; - let key = new_name.clone().unwrap_or(name.clone()); - order.retain(|k| *k != key); - order.insert(to.min(order.len()), key); - } - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - if let Some((v, nv)) = &rename_value - && pairs.get(&name) == Some(v) - { - pairs.insert(name.clone(), nv.clone()); - } - if let Some(nn) = &new_name - && let Some(v) = pairs.remove(&name) - { - pairs.insert(nn.clone(), v); - } - doc.get_mut(*c)?.name = variant_name(&pairs, &order); - } - // The default variant is the top-left one: the variant with that value (and the default's - // other values, when there is one) trades places with it. - if let Some(dv) = string(p, "default") { - let key = new_name.clone().unwrap_or(name.clone()); - let current = default_variant(doc, set); - let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - wanted.insert(key.clone(), dv.clone()); - let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); - let pick = - comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); - let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; - if f != d { - let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); - let (fp, dp) = (place(doc, f), place(doc, d)); - for (c, (x, y)) in [(f, dp), (d, fp)] { - let n = doc.get_mut(c)?; - let [a, b, cc, dd, ..] = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); - } - fit_set(doc, set); - } - } - return ok(); - } - let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; - let preferred = if get(p, "preferred").is_some() { - if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { - return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); - } - Some(preferred_arg(cmd, doc, p)?) - } else { - None - }; - let mut new_def = match get(p, "default") { - Some(v) => match coerce(cmd, doc, &name, &def, v)? { - Value::Bool(b) => PropDef::Boolean { default: b }, - Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, - other => match (&def, id_of(&other)) { - (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, - _ => def.clone(), - }, - }, - None => def.clone(), - }; - if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { - *preferred = list; - } - for c in &comps { - let had = prop_defs(doc, *c).contains_key(&name); - if !had { - continue; - } - let key = new_name.clone().unwrap_or(name.clone()); - let nd = new_def.clone(); - let old = name.clone(); - set_prop_defs(doc, *c, move |m| { - m.remove(&old); - m.insert(key, nd); - })?; - if let Some(nn) = &new_name { - rename_prop_refs(doc, *c, &name, Some(nn))?; - } - } - // The main's bound layers show the new default. - if let PropDef::Boolean { default } = new_def { - set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); - } - ok() -} - -fn delete_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.deleteProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Owner::Set(set) = owner - && variant_options(doc, set).iter().any(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - pairs.remove(&name); - let n = doc.get_mut(*c)?; - n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; - } - return ok(); - } - let mut found = false; - for c in &comps { - if prop_defs(doc, *c).contains_key(&name) { - found = true; - set_prop_defs(doc, *c, |m| { - m.remove(&name); - })?; - rename_prop_refs(doc, *c, &name, None)?; - } - } - if !found { - return Err(bad(cmd, format!("no property named `{name}`"))); - } - ok() -} - -fn def_json(name: &str, d: &PropDef) -> Value { - match d { - PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), - PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), - PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), - PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), - PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), - } -} - -/// Properties of a component, set or instance as JSON (`component.properties`). -pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let (owner_comp, values) = match &n.kind { - NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), - _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), - }; - let Some(comp) = owner_comp else { return json!({"properties": []}) }; - let mut list: Vec = Vec::new(); - if let Some(set) = variant_set(doc, comp) { - let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); - let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - for (k, vals) in variant_options(doc, set) { - let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); - if values.is_some() { - v["value"] = json!(current.get(&k)); - } - list.push(v); - } - } - // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. - let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); - for (k, d) in prop_defs(doc, comp) { - let mut v = def_json(&k, &d); - if !takes_value(&d) { - let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); - if values.is_some() { - v["value"] = layers.first().map_or(Value::Null, |l| { - let count = doc.node(*l).map_or(0, |n| n.children.len()); - json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) - }); - } else { - v["layers"] = json!(layers); - } - } else if let Some(vals) = &values { - v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); - } - list.push(v); - } - let set = variant_set(doc, comp); - let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); - json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) -} diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs new file mode 100644 index 000000000..f6600c10e --- /dev/null +++ b/crates/engine/src/cmd/component/instance.rs @@ -0,0 +1,409 @@ +use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; +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"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut out = Vec::new(); + for i in ids { + if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { + continue; + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); + } + // Own layers lose their link (slot content stays as it is); nested instances stay + // instances of their own mains. + let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; + let n = doc.get_mut(i)?; + n.kind = NodeKind::Frame; + n.source = None; + n.overrides.clear(); + n.prop_refs.remove("mainComponent"); + // No properties left to drive. + n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + for nn in nested { + rebase_instance(doc, nn); + } + out.push(i); + } + if out.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + +pub(super) fn reset_instance(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetInstance"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut done = Vec::new(); + for i in ids { + let Some(inst) = owning_instance(doc, i) else { continue }; + let top = !inside_instance(doc, inst); + for d in doc.subtree(inst) { + let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; + if let Some(n) = doc.node(d) + && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) + && let Some(n) = doc.node_mut(d) + { + n.overrides.retain(|g| keep.contains(&g.as_str())); + } + } + // Property values go back to the defaults. + if top + && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind + { + *props = defaults; + } + // ...and stop following variables. + if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { + doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + } + done.push(inst); + } + if done.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + Ok(json!({"ids": done})) +} + +pub(super) fn reset_override(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetOverride"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; + let group = string(p, "group"); + if let Some(g) = &group + && !OVERRIDE_GROUPS.contains(&g.as_str()) + { + return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); + } + let doc = s.doc_mut()?; + if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { + return Err(bad(cmd, "not a layer of an instance")); + } + let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); + let had = doc.get(i)?.overrides.clone(); + let n = doc.get_mut(i)?; + match &group { + Some(g) => { + n.overrides.remove(g); + } + None => n.overrides.clear(), + } + let mut changed = had != doc.get(i)?.overrides; + // The instance's own property values reset with `props`. + if top_root + && group.as_deref().is_none_or(|g| g == "props") + && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind + { + *props = defaults; + changed = true; + } + Ok(json!({"changed": changed})) +} + +/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. +fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { + let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); + let base = if base.is_empty() { "Slot".to_string() } else { base }; + let taken = |n: &str| { + owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) + || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) + }; + (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) +} + +/// The `preferred` components of a slot property: component or component set ids. +pub(super) fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { + let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; + let mut out = Vec::new(); + for x in list.iter().take(500) { + let c = id_of(x) + .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) + .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; + if !out.contains(&c) { + out.push(c); + } + } + Ok(out) +} + +pub(super) fn make_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.makeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; + let doc = s.doc()?; + let n = doc.get(id)?; + let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); + if !matches!(n.kind, NodeKind::Frame) || !inside_main { + return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); + } + if is_slot(doc, id) { + return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); + } + let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; + let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); + let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) +} + +pub(super) fn remove_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.removeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; + let doc = s.doc()?; + if owning_instance(doc, id).is_some() { + return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); + } + let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; + delete_prop(s, &json!({"id": id, "name": name}))?; + Ok(json!({"id": id, "name": name})) +} + +/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. +fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { + let slot = match name { + Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, + None => id, + }; + if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { + return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); + } + Ok(slot) +} + +pub(super) fn slot_insert(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.slotInsert"; + let c = req_id(cmd, p, "component")?; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let index = match get(p, "index").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), + }; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + if boolean(p, "replace").unwrap_or(false) { + for k in doc.get(slot)?.children.clone() { + doc.remove(k)?; + } + } + let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; + s.set_selection(vec![i]); + Ok(json!({"id": i, "slot": slot})) +} + +pub(super) fn reset_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.resetSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; + let changed = slot_edited(doc, slot); + if changed { + doc.get_mut(slot)?.overrides.remove(SLOT); + } + Ok(json!({"id": slot, "changed": changed})) +} + +pub(super) fn push_changes(s: &mut Session, p: &Value) -> Result { + let cmd = "component.pushChanges"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + // Changes go to the outer instance's main (never into a nested instance's own main). + let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). + let mut pushed = 0; + for d in doc.subtree(inst) { + let Some(n) = doc.node(d).cloned() else { continue }; + let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; + if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { + continue; + } + let Some(mut target) = doc.node(src).cloned() else { continue }; + let groups: BTreeSet = n + .overrides + .iter() + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) + .cloned() + .collect(); + for g in &groups { + copy_group(&n, &mut target, g); + } + if groups.is_empty() { + continue; + } + pushed += 1; + if let Some(slot) = doc.node_mut(src) { + *slot = target; + } + if let Some(m) = doc.node_mut(d) { + m.overrides.retain(|g| !groups.contains(g)); + } + } + Ok(json!({"layers": pushed})) +} + +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + let mut defs = BTreeMap::new(); + for (k, v) in props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + map +} + +pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result { + let cmd = "component.restoreMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "select an instance")); + }; + if doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); + } + let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; + let index = doc.index_in_parent(i); + // The main comes back with its old id (other instances point to it) and its layers with the + // ids their copies mirror, so every instance re-links by source. + let sub = doc.subtree(i); + let map = restore_id_map(doc, i, component, &sub); + let mut nodes = Vec::new(); + let mut nested = Vec::new(); + for d in &sub { + let Some(mut n) = doc.node(*d).cloned() else { continue }; + if *d == i { + let defs = restore_prop_defs(doc, &props); + n.kind = NodeKind::Component { description: String::new(), props: defs }; + n.source = None; + n.overrides.clear(); + n.prop_refs.clear(); + } else { + let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); + if parent_inst != Some(i) { + nodes.push(n); + continue; + } + if matches!(n.kind, NodeKind::Instance { .. }) { + nested.push(*d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nodes.push(n); + } + doc.remove(i)?; + doc.paste_nodes(&nodes, &map, i, parent, index)?; + for nn in nested { + if let Some(m) = map.get(&nn).copied() { + rebase_instance(doc, m); + } + } + s.set_selection(vec![component]); + Ok(json!({"id": component})) +} + +pub(super) fn swap(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swap"; + let c = req_id(cmd, p, "component")?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + for i in ids { + swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; + } + ok() +} + +/// Make instance `i` show main component `c`, keeping the values of properties with the same names +/// (the layers' overrides follow by name when the instance is rebuilt). +pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { + if doc.is_ancestor(c, i) { + return Err("an instance can't show the component it is inside".into()); + } + let defaults = doc.node(c).map(default_props).unwrap_or_default(); + match doc.get_mut(i).map(|n| &mut n.kind) { + Ok(NodeKind::Instance { component, props }) => { + *component = c; + let old = std::mem::take(props); + *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); + Ok(()) + } + _ => Err(format!("{i} is not an instance")), + } +} + +/// Swap the instance under a page point to a component (`instance.swapAt`). +pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swapAt"; + let c = req_id(cmd, p, "component")?; + let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let page = s.page()?; + let doc = s.doc()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let family = crate::drop::swap_family(doc, c); + let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + keep_text_overrides(s.doc_mut()?, target); + swap(s, &json!({"ids": [target], "component": c}))?; + s.set_selection(vec![target]); + Ok(json!({"id": target, "component": c})) +} diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs new file mode 100644 index 000000000..bb3dba4cb --- /dev/null +++ b/crates/engine/src/cmd/component/props.rs @@ -0,0 +1,614 @@ +use super::instance::preferred_arg; +use super::variants::{fit_set, wrap_in_set}; +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). +pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { + return Err(format!("{id} is not an instance (component properties are bound on instances)")); + }; + if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { + return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; + } + match (prop_defs(doc, *component).get(name), kind) { + (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), + (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), + (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), + (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), + (None, _) => Err(format!("the instance has no property named `{name}`")), + } +} + +/// Write bound variable values into instance `id`'s boolean and text property values, and switch +/// it to the variant its bound variant properties name (only what changed; the instance then +/// follows like after `instance.setProperties`). A value that names no variant changes nothing. +pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; + let component = *component; + if let Some(set) = variant_set(doc, component) { + let options = variant_options(doc, set); + let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut wanted = current.clone(); + for (name, val) in sets { + if let VariableValue::String(t) = val + && options.iter().any(|(o, vals)| o == name && vals.contains(t)) + { + wanted.insert(name.clone(), t.clone()); + } + } + if wanted != current + && let Some(target) = find_variant(doc, set, &wanted) + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { component: c, .. } = &mut n.kind + { + *c = target; + } + } + let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; + let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; + let mut next = props.clone(); + for (name, val) in sets { + let v = match (defs.get(name), val) { + (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), + (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), + _ => continue, + }; + next.insert(name.clone(), v); + } + if next != *props + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { props, .. } = &mut n.kind + { + *props = next; + } +} + +/// Coerce a property value to the definition's type. +fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { + match def { + PropDef::Boolean { .. } => match v { + Value::Bool(b) => Ok(json!(b)), + Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), + _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), + }, + PropDef::Text { .. } => match v { + Value::String(t) => Ok(json!(t)), + Value::Number(n) => Ok(json!(n.to_string())), + _ => Err(bad(cmd, format!("`{name}` is a text property"))), + }, + PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { + Some(c) => Ok(json!(c.0)), + None => Err(bad(cmd, format!("`{name}` takes a component id"))), + }, + PropDef::Variant { options, .. } => { + let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } + } + PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), + } +} + +pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.setProperties"; + let ids = req_targets(cmd, s, p)?; + let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; + let doc = s.doc_mut()?; + for i in ids { + let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, format!("{i} is not an instance"))); + }; + // Variant properties: switch to the matching sibling variant. + let set = variant_set(doc, component); + let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); + let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut target = component; + let mut rest = Vec::new(); + for (k, v) in &props { + if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { + let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if !vals.contains(&val) { + return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); + } + wanted.insert(k.clone(), val); + } else { + rest.push((k.clone(), v.clone())); + } + } + if let Some(set) = set + && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() + { + target = find_variant(doc, set, &wanted).unwrap_or(component); + } + let defs = match doc.node(target).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + }; + let mut values = Vec::new(); + for (k, v) in rest { + let def = defs.get(&k).ok_or_else(|| { + let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); + bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) + })?; + values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); + } + if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { + *c = target; + for (k, v) in values { + ip.insert(k, v); + } + for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { + ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); + } + } + } + ok() +} + +/// Where component properties live: a set (its variants share them) or a lone component. +pub(super) enum Owner { + Set(NodeId), + Comp(NodeId), +} + +impl Owner { + pub(super) fn resolve(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + match n.kind { + NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), + _ => { + let c = owning_component(doc, id)?; + Some(match variant_set(doc, c) { + Some(s) => Owner::Set(s), + None => Owner::Comp(c), + }) + } + } + } + + pub(super) fn components(&self, doc: &Document) -> Vec { + match self { + Owner::Comp(c) => vec![*c], + Owner::Set(s) => doc + .node(*s) + .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) + .unwrap_or_default(), + } + } + + fn id(&self) -> NodeId { + match self { + Owner::Set(s) | Owner::Comp(s) => *s, + } + } +} + +pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { + match doc.node(c).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + } +} + +fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { + if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { + f(props); + } + Ok(()) +} + +fn valid_prop_name(cmd: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { + return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); + } + Ok(name.to_string()) +} + +pub(super) fn add_prop(s: &mut Session, p: &Value) -> Result { + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; + let t = req_string(cmd, p, "type")?; + let doc = s.doc_mut()?; + if inside_instance(doc, target) { + return Err(bad(cmd, "select a component, not a layer of an instance")); + } + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; + // A layer selected inside the component gets bound to the new property. + let layer = id(p, "layer") + .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); + let comps = owner.components(doc); + let variants = match &owner { + Owner::Set(s) => variant_options(doc, *s), + Owner::Comp(_) => Vec::new(), + }; + if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { + return Err(bad(cmd, format!("a property named `{name}` already exists"))); + } + let layer_node = layer.and_then(|l| doc.node(l)).cloned(); + let def = match t.to_ascii_lowercase().as_str() { + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, + "text" => PropDef::Text { + default: string(p, "default") + .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) + .unwrap_or_else(|| name.clone()), + }, + "instanceswap" | "instance_swap" | "instance" => { + let d = id(p, "default") + .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + }) + .and_then(|c| main_of(doc, c)) + .ok_or_else(|| bad(cmd, "default: a component id"))?; + PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } + } + "variant" => { + let dv = string(p, "default") + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty() && !d.contains([',', '='])) + .unwrap_or_else(|| "Default".into()); + let set = match owner { + Owner::Set(s) => s, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, + }; + for c in Owner::Set(set).components(doc) { + let cn = doc.get_mut(c)?; + cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; + } + s.set_selection(vec![set]); + return Ok(json!({"id": set})); + } + "slot" => { + if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { + return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); + } + PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } + } + _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), + }; + for c in &comps { + set_prop_defs(doc, *c, |m| { + m.insert(name.clone(), def.clone()); + })?; + // Existing instances get the default. + if !takes_value(&def) { + continue; + } + for inst in instances_of(doc, *c) { + if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { + props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); + } + } + } + if let Some(l) = layer { + bind(cmd, doc, l, Some(&name), None)?; + } + Ok(json!({"id": owner.id(), "name": name})) +} + +/// Bind (or with `property: None` unbind) a layer's field to a property of its component. +fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { + let n = doc.get(layer)?.clone(); + if inside_instance(doc, layer) { + return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); + } + let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; + let Some(property) = property.filter(|p| !p.is_empty()) else { + let n = doc.get_mut(layer)?; + match field { + Some(f) => { + n.prop_refs.remove(f); + } + None => n.prop_refs.clear(), + } + return Ok(()); + }; + let defs = prop_defs(doc, comp); + let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; + let expected = match def { + PropDef::Boolean { .. } => "visible", + PropDef::Text { .. } => "characters", + PropDef::InstanceSwap { .. } => "mainComponent", + PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), + PropDef::Slot { .. } => SLOT, + }; + let field = field.unwrap_or(expected); + if field != expected { + return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); + } + match field { + "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), + "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), + "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), + SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), + SLOT if doc + .subtree(comp) + .into_iter() + .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => + { + return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); + } + _ => {} + } + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } + Ok(()) +} + +pub(super) fn bind_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.bindProperty"; + let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; + let property = string(p, "property"); + let field = string(p, "field"); + if let Some(f) = &field + && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) + { + return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); + } + let doc = s.doc_mut()?; + bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; + ok() +} + +/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. +fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { + for d in doc.subtree(comp) { + let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); + if refs.values().any(|v| v == old) + && let Some(n) = doc.node_mut(d) + { + match new { + Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), + None => n.prop_refs.retain(|_, v| v != old), + } + } + } + // Instances' values and the variables bound to them follow (a deleted property drops both). + let old_key = format!("{PROP_BINDING}{old}"); + for inst in instances_of(doc, comp) { + let n = doc.get_mut(inst)?; + if let NodeKind::Instance { props, .. } = &mut n.kind + && let Some(v) = props.remove(old) + && let Some(nw) = new + { + props.insert(nw.to_string(), v); + } + if let Some(var) = n.bound.remove(&old_key) + && let Some(nw) = new + { + n.bound.insert(format!("{PROP_BINDING}{nw}"), var); + } + } + Ok(()) +} + +pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.editProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Some(nn) = &new_name { + let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) + || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); + if taken { + return Err(bad(cmd, format!("a property named `{nn}` already exists"))); + } + } + // Variant property of a set. + if let Owner::Set(set) = owner + && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + let rename_value = match (string(p, "value"), string(p, "newValue")) { + (Some(v), Some(nv)) => { + let nv = nv.trim().to_string(); + if !values.contains(&v) { + return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); + } + if nv.is_empty() || nv.contains([',', '=']) { + return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); + } + Some((v, nv)) + } + _ => None, + }; + let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + // Reordered: every variant's name lists the properties in the new order. + if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { + let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; + let key = new_name.clone().unwrap_or(name.clone()); + order.retain(|k| *k != key); + order.insert(to.min(order.len()), key); + } + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + if let Some((v, nv)) = &rename_value + && pairs.get(&name) == Some(v) + { + pairs.insert(name.clone(), nv.clone()); + } + if let Some(nn) = &new_name + && let Some(v) = pairs.remove(&name) + { + pairs.insert(nn.clone(), v); + } + doc.get_mut(*c)?.name = variant_name(&pairs, &order); + } + // The default variant is the top-left one: the variant with that value (and the default's + // other values, when there is one) trades places with it. + if let Some(dv) = string(p, "default") { + let key = new_name.clone().unwrap_or(name.clone()); + let current = default_variant(doc, set); + let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + wanted.insert(key.clone(), dv.clone()); + let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); + let pick = + comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); + let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; + if f != d { + let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); + let (fp, dp) = (place(doc, f), place(doc, d)); + for (c, (x, y)) in [(f, dp), (d, fp)] { + let n = doc.get_mut(c)?; + let [a, b, cc, dd, ..] = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); + } + fit_set(doc, set); + } + } + return ok(); + } + let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; + let preferred = if get(p, "preferred").is_some() { + if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { + return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); + } + Some(preferred_arg(cmd, doc, p)?) + } else { + None + }; + let mut new_def = match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { + *preferred = list; + } + for c in &comps { + let had = prop_defs(doc, *c).contains_key(&name); + if !had { + continue; + } + let key = new_name.clone().unwrap_or(name.clone()); + let nd = new_def.clone(); + let old = name.clone(); + set_prop_defs(doc, *c, move |m| { + m.remove(&old); + m.insert(key, nd); + })?; + if let Some(nn) = &new_name { + rename_prop_refs(doc, *c, &name, Some(nn))?; + } + } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } + ok() +} + +pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.deleteProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Owner::Set(set) = owner + && variant_options(doc, set).iter().any(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + pairs.remove(&name); + let n = doc.get_mut(*c)?; + n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; + } + return ok(); + } + let mut found = false; + for c in &comps { + if prop_defs(doc, *c).contains_key(&name) { + found = true; + set_prop_defs(doc, *c, |m| { + m.remove(&name); + })?; + rename_prop_refs(doc, *c, &name, None)?; + } + } + if !found { + return Err(bad(cmd, format!("no property named `{name}`"))); + } + ok() +} + +fn def_json(name: &str, d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), + PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), + PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), + PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), + } +} + +/// Properties of a component, set or instance as JSON (`component.properties`). +pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let (owner_comp, values) = match &n.kind { + NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), + _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), + }; + let Some(comp) = owner_comp else { return json!({"properties": []}) }; + let mut list: Vec = Vec::new(); + if let Some(set) = variant_set(doc, comp) { + let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); + let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + for (k, vals) in variant_options(doc, set) { + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); + if values.is_some() { + v["value"] = json!(current.get(&k)); + } + list.push(v); + } + } + // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. + let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); + for (k, d) in prop_defs(doc, comp) { + let mut v = def_json(&k, &d); + if !takes_value(&d) { + let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); + if values.is_some() { + v["value"] = layers.first().map_or(Value::Null, |l| { + let count = doc.node(*l).map_or(0, |n| n.children.len()); + json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) + }); + } else { + v["layers"] = json!(layers); + } + } else if let Some(vals) = &values { + v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); + } + list.push(v); + } + let set = variant_set(doc, comp); + let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); + json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) +} diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs new file mode 100644 index 000000000..5f9e218ac --- /dev/null +++ b/crates/engine/src/cmd/component/variants.rs @@ -0,0 +1,178 @@ +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 { + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; + if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); + } + let bounds = doc.bounds_of(ids).unwrap_or_default(); + let pw = doc.world_transform(parent); + let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); + let set = doc.alloc_id(); + let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); + n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); + n.fills.clear(); + doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; + for i in ids { + doc.reparent(*i, set, None)?; + } + Ok(set) +} + +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { + if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None + } +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; + for (k, i) in ids.iter().enumerate() { + let node = doc.get_mut(*i)?; + if !node.name.contains('=') { + node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); + } + } + Ok(set) +} + +pub(super) fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; + s.set_selection(vec![set]); + Ok(json!({"id": set})) +} + +/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). +pub(super) fn fit_set(doc: &mut Document, set: NodeId) { + let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; + let (mut w, mut h) = (0.0f64, 0.0f64); + for c in kids { + if let Some(n) = doc.node(c) { + let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); + w = w.max(b.x1 + 20.0); + h = h.max(b.y1 + 20.0); + } + } + if let Some(n) = doc.node_mut(set) { + n.width = n.width.max(w); + n.height = n.height.max(h); + } +} + +pub(super) fn add_variant(s: &mut Session, p: &Value) -> Result { + let cmd = "component.addVariant"; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; + let doc = s.doc_mut()?; + let (set, base) = match doc.get(t)?.kind { + NodeKind::ComponentSet { .. } => { + let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; + (t, last) + } + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), + }; + let options = variant_options(doc, set); + let mut pairs = variant_props(&doc.get(base)?.name); + let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); + let existing: BTreeSet> = + doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); + match (string(p, "property"), string(p, "value")) { + (Some(prop), Some(value)) => { + let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); + if !options.iter().any(|(k, _)| *k == prop) { + return Err(bad(cmd, format!("unknown variant property `{prop}`"))); + } + if value.is_empty() || value.contains([',', '=']) { + return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); + } + pairs.insert(prop, value); + if existing.contains(&pairs) { + return Err(bad(cmd, "that variant already exists")); + } + } + (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), + _ => { + let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); + let mut k = existing.len() + 1; + loop { + pairs.insert(prop.clone(), format!("Variant{k}")); + if !existing.contains(&pairs) || k > 10_000 { + break; + } + k += 1; + } + } + } + let name = variant_name(&pairs, &order); + let bottom = doc + .get(set)? + .children + .iter() + .filter_map(|c| doc.node(*c)) + .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) + .fold(0.0f64, f64::max); + let dup = doc.duplicate_into(base, set, None)?; + let n = doc.get_mut(dup)?; + n.name = name; + let c = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); + fit_set(doc, set); + s.set_selection(vec![dup]); + Ok(json!({"id": dup})) +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 414a2310e..2e64ce042 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 60077efd5..2d51b958e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,14 +2,33 @@ 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; +mod props; +mod select; + +pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into}; +use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace}; +use props::{ + batch_rename, effect_add, effect_copy, effect_paste, find_replace, paint_copy, paint_list, paint_paste, paint_set, pattern_fill, reparent, set, + stroke_brush, width_profile, +}; +pub use select::opacity_from_digits; +use select::{ + hide_others, layers_at, nudge, nudge_size, opacity_keys, select_all, select_area, select_children, select_inverse, select_matching, + select_parent, select_same, select_sibling, toggle, +}; + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -536,1054 +555,3 @@ fn bump_name(n: &str) -> String { } format!("{n} 2") } - -/// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { - let ds = s.ds()?; - // Pages are not layers: they can't be pasted anywhere. - let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); - if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); - } - let (nodes, images, bounds) = copied(&ds.doc, &layers); - let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); - s.clipboard.source = Some(source); - s.clipboard.source_path = source_path; - s.clipboard.source_doc = uid; - s.clipboard.copied_components = nodes - .iter() - .filter(|(_, sub)| { - cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) - }) - .map(|(root, _)| *root) - .collect(); - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); - Ok(()) -} - -type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); - -fn copied(doc: &Document, ids: &[NodeId]) -> Copied { - let ids = z_sorted(doc, &roots(doc, ids)); - let mut nodes = Vec::new(); - let mut images = BTreeMap::new(); - for i in &ids { - let world = doc.world_transform(*i); - let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); - // Store the root's transform in page space so pastes land where it was. - if let Some(r) = sub.first_mut() { - r.transform = world; - } - for n in &sub { - for p in n.fills.iter().chain(n.strokes.iter()) { - if let figcraft_color::PaintKind::Image(img) = &p.kind - && let Some(d) = doc.images.get(&img.image) - { - images.insert(img.image.clone(), d.clone()); - } - } - } - nodes.push((*i, sub)); - } - let bounds = doc.bounds_of(&ids); - (nodes, images, bounds) -} - -/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. -pub const CLIP_OPEN: &str = "(figma)"; -pub const CLIP_CLOSE: &str = "(/figma)"; -/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its -/// base64 is checked first, so neither the text nor the decoded bytes can pass it. -const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; -const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; - -/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. -fn clip_base64(text: &str) -> Option> { - use base64::Engine as _; - if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { - return None; - } - let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() -} - -/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in -/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML -/// holds no readable design data (it may just mention the marker). -pub fn design_clipboard_buffer(html: &str) -> Option> { - let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); - let len = html.get(start..)?.find(CLIP_CLOSE)?; - let bytes = clip_base64(html.get(start..start + len)?)?; - figcraft_format::fig::is_fig(&bytes).then_some(bytes) -} - -/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, -/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, -/// store them as a foreign-file clipboard source with a bumped layers generation, then -/// delegate to `paste` and append import warnings. -fn paste_design_layers(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteDesignLayers"; - let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, - (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, - (None, None) => return Err(bad(CMD, "pass `html` or `data`")), - }; - let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; - let doc = std::sync::Arc::new(other.doc); - let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); - if tops.is_empty() { - return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); - } - let (nodes, images, bounds) = copied(&doc, &tops); - // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it - // still pastes afterwards. - let props = s.clipboard.props.take(); - let paint = s.clipboard.paint.take(); - let stroke = s.clipboard.stroke.take(); - let effect = s.clipboard.effect.take(); - // No open file has id 0. So paste treats this copy as a copy from a different file. - let layers_generation = s.clipboard.layers_generation.wrapping_add(1); - s.clipboard = crate::Clipboard { - nodes, - images, - props, - paint, - stroke, - effect, - bounds, - source_doc: 0, - source: Some(doc), - source_path: None, - copied_components: BTreeSet::new(), - layers_generation, - }; - let mut out = paste(s, p)?; - if !other.issues.is_empty() { - out["warnings"] = json!(other.issues); - } - Ok(out) -} - -fn copy(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, "edit.copy", &ids)?; - Ok(json!({"copied": s.clipboard.nodes.len()})) -} - -fn cut(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, "edit.cut", &ids)?; - let doc = s.doc()?; - let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); - delete(s, &json!({"ids": layers})) -} - -/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). -pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { - let clip = s.clipboard.clone(); - if clip.nodes.is_empty() { - return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); - } - // From another file: its instances follow the mains this file can have. - let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); - let copy_local = s.ds()?.uid == clip.source_doc; - let doc = s.doc_mut()?; - for (k, v) in &clip.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - // Sections go on the page (or in sections), never in the frame being pasted into. - let page = doc.page_of(parent).unwrap_or(parent); - let shift = match (at, clip.bounds) { - (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), - _ => Affine::IDENTITY, - }; - let mut out = Vec::new(); - let mut all = BTreeMap::new(); - for (root, nodes) in &clip.nodes { - if copy_local - && clip.copied_components.contains(root) - && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) - && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) - { - let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; - doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; - out.push(instance); - continue; - } - let mut map = BTreeMap::new(); - for n in nodes { - map.insert(n.id, doc.alloc_id()); - } - let mut nodes = nodes.clone(); - let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); - let into = if section && !doc.takes_sections(parent) { page } else { parent }; - if let Some(r) = nodes.first_mut() { - r.transform = doc.world_transform(into).inverse() * shift * r.transform; - } - out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); - all.extend(map); - } - // Board connectors pasted with what they join join the pasted copies. - let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = - clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); - crate::board::rejoin(doc, &originals, &all, !copy_local); - if let Some(src) = foreign { - crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; - } - Ok(out) -} - -fn paste(s: &mut Session, p: &Value) -> Result { - let page = s.page()?; - // Figma pastes into the selected frame (if one is selected), at the same position when it fits, - // else in the frame's centre; otherwise in place on the page (or the viewport centre). - let sel = s.selection(); - let doc = s.doc()?; - let copy_local = s.ds()?.uid == s.clipboard.source_doc; - let target = id(p, "parent").or_else(|| { - sel.first() - .copied() - .filter(|i| { - sel.len() == 1 - && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) - }) - .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) - }); - let parent = target.unwrap_or(page); - let at = match (num(p, "x"), num(p, "y")) { - (Some(x), Some(y)) => Some((x, y)), - _ => { - let b = s.clipboard.bounds; - match (target, b) { - (Some(t), Some(b)) => { - let tb = doc.world_bounds(t); - match tb { - Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, - Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), - None => None, - } - } - (None, Some(b)) => { - // Off-screen originals paste at the viewport centre. - let v = &s.ds()?.view; - let (ox, oy) = v.origin(); - let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); - if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } - } - _ => None, - } - } - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_over(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc()?; - let (parent, at) = match sel.first() { - Some(i) => { - let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); - (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) - } - None => (page, None), - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_replace(s: &mut Session, p: &Value) -> Result { - let targets = req_targets("edit.pasteReplace", s, p)?; - let mut out = Vec::new(); - for t in targets { - let doc = s.doc()?; - let Some(n) = doc.node(t) else { continue }; - let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; - let idx = doc.index_in_parent(t); - let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); - let ids = paste_into(s, parent, at)?; - let doc = s.doc_mut()?; - for (k, i) in ids.iter().enumerate() { - if let Some(ix) = idx { - let _ = doc.reorder(*i, ix + k); - } - } - doc.remove(t)?; - out.extend(ids); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn copy_props_of(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let mut v = json!({ - "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, - "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, - "cornerSmoothing": n.corners.smoothing, - }); - if let NodeKind::Text(t) = &n.kind { - v["fontFamily"] = json!(t.style.font_family); - v["fontStyle"] = json!(t.style.font_style); - v["fontSize"] = json!(t.style.font_size); - v["fontVariations"] = json!(t.style.variations); - v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); - v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); - } - v -} - -fn copy_props(s: &mut Session, p: &Value) -> Result { - let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; - let v = copy_props_of(s.doc()?, id); - s.clipboard.props = Some(v.clone()); - Ok(v) -} - -fn paste_props(s: &mut Session, p: &Value) -> Result { - let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; - let ids = req_targets("edit.pasteProperties", s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let mut m = props.as_object().cloned().unwrap_or_default(); - let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); - if !is_text { - m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); - } - if !doc.get(i)?.kind.has_corner_radius() { - m.remove("cornerRadius"); - m.remove("cornerSmoothing"); - } - super::props::apply("edit.pasteProperties", doc, i, &m)?; - } - ok() -} - -fn layers_at(s: &mut Session, p: &Value) -> Result { - let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); - let doc = s.doc()?; - let zoom = s.ds()?.view.zoom.max(1e-6); - let sel = s.selection(); - let result = crate::booleans::result_outline(doc, &sel); - let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) - .into_iter() - .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) - .collect(); - Ok(json!({"layers": layers})) -} - -fn select_all(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - // Select all siblings of the current selection, else the page's top level. - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// What Select all would select, less the selection (hidden and locked layers neither). -fn select_inverse(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = - doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. -fn select_area(s: &mut Session, p: &Value) -> Result { - let cmd = "select.area"; - let keys = ["x", "y", "width", "height"]; - if keys.iter().all(|k| p.get(*k).is_none()) { - s.request_ui("selectArea", json!({})); - return ok(); - } - let [x, y, w, h] = keys.map(|k| num(p, k)); - let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; - if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { - return Err(bad(cmd, "the box is out of range")); - } - let start = figcraft_geom::Point::new(x, y); - let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); - let add = boolean(p, "add") == Some(true); - let base = if add { s.selection() } else { Vec::new() }; - let within = s.marquee_within(start)?; - let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; - s.set_selection(sel.clone()); - Ok(json!({"selection": sel})) -} - -fn select_children(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids = Vec::new(); - for i in s.selection() { - if let Some(n) = doc.node(i) { - ids.extend(n.children.iter().copied()); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_parent(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids: Vec = Vec::new(); - for i in s.selection() { - if let Some(p) = doc.node(i).and_then(|n| n.parent) - && !doc.node(p).is_some_and(Node::is_page) - && !ids.contains(&p) - { - ids.push(p); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_sibling(s: &mut Session, dir: i64) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(cur) = sel.last().copied() else { - let first = doc.get(s.page()?)?.children.last().copied(); - s.set_selection(first.into_iter().collect()); - return Ok(json!({"selection": s.selection()})); - }; - let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; - let kids = &doc.get(parent)?.children; - let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; - let n = kids.len() as i64; - // Layers panel order is reversed (top of the list = last child): Tab goes down the list. - let next = (idx - dir).rem_euclid(n.max(1)) as usize; - let id = kids.get(next).copied().unwrap_or(cur); - s.set_selection(vec![id]); - Ok(json!({"selection": [id]})) -} - -fn select_matching(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; - let (name, ty) = (first.name.clone(), first.kind.type_name()); - let page = s.page()?; - let ids: Vec = - doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; - let page = s.page()?; - let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in -/// quick succession the exact percentage. -pub fn opacity_from_digits(digits: &str) -> Option { - let mut n = 0u32; - let mut count = 0; - for c in digits.chars() { - // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. - n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; - count += 1; - } - match count { - 1 if n == 0 => Some(1.0), - 1 => Some(n as f32 / 10.0), - 2 => Some(n as f32 / 100.0), - _ => None, - } -} - -fn opacity_keys(s: &mut Session, p: &Value) -> Result { - let digits = req_string("object.opacityKeys", p, "digits")?; - let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - let ids = req_targets("object.opacityKeys", s, p)?; - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - doc.get_mut(i)?.opacity = o; - } - super::styles::detach_edited(doc, &before); - Ok(json!({"opacity": o})) -} - -fn nudge(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); - let (dx, dy) = (ux * k, uy * k); - let ids = req_targets("transform.nudge", s, p)?; - // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds - // no undo step. - let mut work = s.doc()?.clone(); - let doc = &mut work; - let ids = roots(doc, &ids); - let mut changed = false; - // Children in an auto layout flow move one place instead (see `al_keys`). - for i in &ids { - if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { - let n = doc.get_mut(*i)?; - n.transform = Affine::translate((dx, dy)) * n.transform; - changed = true; - } - } - for dir in crate::al_keys::Dir::from_units(ux, uy) { - // Grouped per arrow: a move into a neighbouring frame changes the parent. - let mut groups: BTreeMap> = BTreeMap::new(); - for i in &ids { - if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { - groups.entry(parent).or_default().push(*i); - } - } - for (parent, block) in groups { - changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; - } - } - if changed { - s.ds_mut()?.doc = std::sync::Arc::new(work); - } - ok() -} - -fn nudge_size(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); - let ids = req_targets("transform.nudgeSize", s, p)?; - let ids = roots(s.doc()?, &ids); - for i in ids { - let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; - let mut q = serde_json::Map::new(); - q.insert("ids".into(), json!([i])); - // Only the axis that changes becomes fixed. - if dw != 0.0 { - q.insert("width".into(), json!((w + dw).max(0.0))); - } - if dh != 0.0 { - q.insert("height".into(), json!((h + dh).max(0.0))); - } - super::arrange::resize_cmd(s, &Value::Object(q))?; - } - ok() -} - -fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { - let ids = req_targets("object.toggle", s, p)?; - let doc = s.doc_mut()?; - let explicit = boolean(p, what); - // Figma: if any is visible, hide all (and vice versa). - let value = explicit.unwrap_or_else(|| match what { - "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), - _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), - }); - for i in &ids { - let n = doc.get_mut(*i)?; - if what == "visible" { - n.visible = value; - } else { - n.locked = value; - } - } - if what == "locked" && value { - s.set_selection(vec![]); - } - Ok(json!({what: value})) -} - -fn hide_others(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc_mut()?; - let keep: BTreeSet = sel - .iter() - .flat_map(|i| { - let mut v = doc.ancestors(*i); - v.push(*i); - v - }) - .collect(); - let tops: Vec = doc.get(page)?.children.clone(); - for t in tops { - if !keep.contains(&t) { - doc.get_mut(t)?.visible = false; - } - } - ok() -} - -fn batch_rename(s: &mut Session, p: &Value) -> Result { - let cmd = "object.batchRename"; - let ids = req_targets(cmd, s, p)?; - let find = string(p, "find"); - let replace = string(p, "replace").unwrap_or_default(); - let pattern = string(p, "name"); - let rename = - crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) - .map_err(|e| bad(cmd, e))?; - let doc = s.doc_mut()?; - let names = rename.plan(doc, &ids); - for (i, name) in &names { - doc.get_mut(*i)?.name = name.clone(); - } - Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) -} - -/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. -/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. -fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { - if needle.is_empty() { - return None; - } - (from..hay.len()) - .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) -} - -fn find_replace(s: &mut Session, p: &Value) -> Result { - let find = req_string("edit.findReplace", p, "find")?; - if find.is_empty() { - return Err(bad("edit.findReplace", "`find` is empty")); - } - // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). - let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); - let names = boolean(p, "layerNames").unwrap_or(false); - let page = s.page()?; - let doc = s.doc_mut()?; - let mut hits = Vec::new(); - let needle: Vec = find.chars().collect(); - let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); - for i in doc.subtree(page) { - let Some(n) = doc.node(i) else { continue }; - let text: Option> = match &n.kind { - NodeKind::Text(t) => Some(t.characters.chars().collect()), - _ => None, - }; - let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); - let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); - if !(text_hit || name_hit) || n.is_page() { - continue; - } - hits.push(i); - if let Some(r) = &replace { - let n = doc.get_mut(i)?; - if names && name_hit { - let chars: Vec = n.name.chars().collect(); - let mut out = String::new(); - let mut at = 0; - while let Some(pos) = find_ci(&chars, &needle, at) { - out.extend(chars.get(at..pos).unwrap_or_default()); - out.push_str(r); - at = pos + needle.len(); - } - out.extend(chars.get(at..).unwrap_or_default()); - n.name = out; - } - if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { - // Replace occurrence by occurrence so styles shift correctly. The search resumes - // after each inserted replacement, so a replacement that matches `find` (in any - // case) neither loops forever nor stops the other occurrences. - let mut cursor = 0; - for _ in 0..10_000 { - let Some(start) = find_ci(&chars, &needle, cursor) else { break }; - t.replace_range(start, start + needle.len(), r); - chars.splice(start..start + needle.len(), r_chars.iter().copied()); - cursor = start + r_chars.len(); - } - } - } - } - if boolean(p, "select").unwrap_or(true) { - s.set_selection(hits.clone()); - } - Ok(json!({"matches": hits})) -} - -/// Keys only some layer types take, the test for those layers, and why the others skip them. -type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); - -fn set(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.set", s, p)?; - let props = match get(p, "props") { - Some(Value::Object(m)) => m.clone(), - _ => { - // Allow flat params: {"id": 3, "fill": "#f00"}. - let mut m = p.as_object().cloned().unwrap_or_default(); - m.remove("id"); - m.remove("ids"); - m - } - }; - if props.is_empty() { - return Err(bad("node.set", "no properties given")); - } - // A new rotation for the selection turns it about its moved rotation origin: each layer's - // point under the origin stays put. - let ds = s.ds()?; - let pivot = - ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); - let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { - Some(o) => roots(&ds.doc, &ids) - .into_iter() - .map(|i| (i, ds.doc.world_transform(i))) - .filter(|(_, w)| w.determinant().abs() > 1e-12) - .map(|(i, w)| (i, w.inverse() * o)) - .collect(), - None => Vec::new(), - }; - let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Type-specific keys go to the layers that take them and are skipped, with a warning, on the - // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets - // that take them, the first refuses them (`props::layout_mut`, `props::apply`). - let doc = &s.ds()?.doc; - let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); - let frame_like = |n: &Node| n.kind.is_frame_like(); - let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); - let typed: [TypedKeys; 2] = [ - (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), - (&path_keys, &vector, "only vector layers have a path"), - ]; - let mut skipped: BTreeMap> = BTreeMap::new(); - for (keys, takes, why) in typed { - let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); - if keys.is_empty() || !ids.iter().any(takes) { - continue; - } - let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); - if others.is_empty() { - continue; - } - for i in &others { - skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); - } - let names: Vec = - others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: {why}", - keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); - } - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; - } - super::styles::detach_edited(doc, &before); - if let Some(o) = pivot { - for (i, q) in pinned { - let d = o - doc.world_transform(i) * q; - if d.hypot().is_finite() { - super::arrange::move_world(doc, i, d.x, d.y)?; - } - } - } - if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } -} - -/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that -/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is -/// Bold), say which face it is. Families that aren't installed, and names that match a style or a -/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. -fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { - let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; - let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { - Some(f) => vec![f.to_string()], - None => ids - .iter() - .filter_map(|i| doc.node(*i)) - .filter_map(|n| n.kind.text()) - .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) - .collect(), - }; - families.sort(); - families.dedup(); - let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); - let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); - families - .into_iter() - .filter_map(|family| { - let styles = figcraft_text::family_styles(&family); - if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { - return None; - } - let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; - let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); - if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { - return None; - } - Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) - }) - .collect() -} - -/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances -/// record the override). -fn width_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.widthProfile"; - let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); - let ids = req_targets(cmd, s, p)?; - let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); - let doc = s.doc_mut()?; - for i in ids { - super::props::apply(cmd, doc, i, &props)?; - } - ok() -} - -fn stroke_brush(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.brush"; - let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; - let brush = super::props::brush_of(&v).ok_or_else(|| bad(cmd, super::props::BRUSH_HELP))?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - if brush.is_some() && n.strokes.is_empty() { - n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); - } - n.stroke.brush = brush.clone(); - } - ok() -} - -fn reparent(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.reparent", s, p)?; - let parent = req_id("node.reparent", p, "parent")?; - let index = num(p, "index").map(|i| i.max(0.0) as usize); - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - // `index` is where the first moved layer ends up among the parent's children (bottom first), - // counted after the move, the way `node.reorder` counts it. The layers land together, keeping - // their order, under the sibling that stays put and ends up just after them (none: on top). - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); - for i in &ids { - // Counted with the layer still in place, as `reparent` expects. - let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); - doc.reparent(*i, parent, at)?; - } - ok() -} - -fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { - let cmd = if fills { "fill" } else { "stroke" }; - let ids = req_targets(cmd, s, p)?; - let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let frameish = n.kind.is_frame_like(); - let list = if fills { &mut n.fills } else { &mut n.strokes }; - if add { - let paint = match get(p, "paint").or_else(|| get(p, "color")) { - Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, - None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), - None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), - None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), - }; - list.push(paint); - } else { - let idx = index.unwrap_or(list.len().saturating_sub(1)); - // The paint's colour binding goes with it; later paints' move down one. - super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx); - } - } - ok() -} - -fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.set" } else { "stroke.set" }; - let ids = req_targets(cmd, s, p)?; - let idx = opt_index(cmd, p, "index")?; - let new_paint = match get(p, "paint") { - Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), - None => None, - }; - let col = color(p, "color"); - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let list = if fills { &mut n.fills } else { &mut n.strokes }; - let at = idx.unwrap_or(list.len().saturating_sub(1)); - if list.is_empty() { - list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); - } - let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; - let old = slot.clone(); - if let Some(np) = &new_paint { - *slot = np.clone(); - } - if let Some(c) = col { - slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; - if c.a < 1.0 { - slot.opacity = c.a; - slot.color_alpha = c.a; - } - } - if let Some(o) = num(p, "opacity") { - slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; - slot.color_alpha = 1.0; - } - if let Some(v) = boolean(p, "visible") { - slot.visible = v; - } - if let Some(b) = blend(p, "blend") { - slot.blend = b; - } - super::lists::rebind_edited_paint(n, fills, at, &old); - if fills { - n.styles.fill = None; - } else { - n.styles.stroke = None; - } - } - ok() -} - -/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke -/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. -fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.copy" } else { "stroke.copy" }; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let at = opt_index(cmd, p, "index")?; - let (paint, stroke) = { - let doc = s.doc()?; - let n = doc.get(id)?; - let list = if fills { &n.fills } else { &n.strokes }; - let at = at.unwrap_or(list.len().saturating_sub(1)); - let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; - (paint, (!fills).then(|| n.stroke.clone())) - }; - s.clipboard.paint = Some((fills, paint.clone())); - s.clipboard.stroke = stroke; - serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) -} - -/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A -/// stroke's copied settings go onto each target too. -fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.paste" } else { "stroke.paste" }; - let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { - return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); - }; - if copied_fills != fills { - let what = if fills { "stroke" } else { "fill" }; - return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); - } - let ids = req_targets(cmd, s, p)?; - // A copied pattern pointing at a target or a layer inside it would make the layer pattern - // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. - if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { - let doc = s.doc()?; - if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { - return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); - } - } - let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); - let out = match opt_index(cmd, p, "index")? { - Some(at) => { - q["index"] = json!(at); - paint_set(s, &q, fills)? - } - None => paint_list(s, &q, fills, true)?, - }; - if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { - for id in ids { - s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); - } - } - Ok(out) -} - -fn pattern_fill(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "fill.pattern"; - let src = req_id(CMD, p, "source")?; - let doc = s.doc()?; - if doc.node(src).is_none_or(|n| n.is_page()) { - return Err(bad(CMD, "`source` must be a layer")); - } - if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { - return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); - } - let mut pt = figcraft_color::PatternPaint::new(src.0); - pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); - pt.align = parse(CMD, p, "align")?.unwrap_or_default(); - pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); - let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; - pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - q["paint"] = - serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) - .map_err(|e| bad(CMD, e.to_string()))?; - paint_set(s, &q, true) -} - -fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { - Some(e) => e, - None => { - let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); - Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? - } - }; - for i in req_targets("effect.add", s, p)? { - s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); - } - ok() -} - -/// Copy effect `index` (the top one by default) of the first target into the clipboard. -fn effect_copy(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.copy"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; - let at = opt_index(CMD, p, "index")?; - let e = { - let doc = s.doc()?; - let list = &doc.get(id)?.effects; - let at = at.unwrap_or(list.len().saturating_sub(1)); - list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? - }; - s.clipboard.effect = Some(e.clone()); - serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) -} - -/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. -fn effect_paste(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.paste"; - let Some(e) = s.clipboard.effect.clone() else { - return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); - }; - let at = opt_index(CMD, p, "index")?; - for id in req_targets(CMD, s, p)? { - let n = s.doc_mut()?.get_mut(id)?; - match at { - Some(i) => { - if let Some(slot) = n.effects.get_mut(i) { - *slot = e.clone(); - } - } - None => n.effects.push(e.clone()), - } - } - ok() -} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 000000000..0df975324 --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,335 @@ +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<()> { + let ds = s.ds()?; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { + let ids = z_sorted(doc, &roots(doc, ids)); + let mut nodes = Vec::new(); + let mut images = BTreeMap::new(); + for i in &ids { + let world = doc.world_transform(*i); + let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); + // Store the root's transform in page space so pastes land where it was. + if let Some(r) = sub.first_mut() { + r.transform = world; + } + for n in &sub { + for p in n.fills.iter().chain(n.strokes.iter()) { + if let figcraft_color::PaintKind::Image(img) = &p.kind + && let Some(d) = doc.images.get(&img.image) + { + images.insert(img.image.clone(), d.clone()); + } + } + } + nodes.push((*i, sub)); + } + let bounds = doc.bounds_of(&ids); + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; + +pub const CLIP_CLOSE: &str = "(/figma)"; + +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; + +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. +pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = crate::cmd::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it + // still pastes afterwards. + let props = s.clipboard.props.take(); + let paint = s.clipboard.paint.take(); + let stroke = s.clipboard.stroke.take(); + let effect = s.clipboard.effect.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + paint, + stroke, + effect, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) +} + +pub(super) fn copy(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.copy", s, p)?; + copy_to_clipboard(s, "edit.copy", &ids)?; + Ok(json!({"copied": s.clipboard.nodes.len()})) +} + +pub(super) fn cut(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.cut", s, p)?; + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) +} + +/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). +pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { + let clip = s.clipboard.clone(); + if clip.nodes.is_empty() { + return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); + } + // From another file: its instances follow the mains this file can have. + let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; + let doc = s.doc_mut()?; + for (k, v) in &clip.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + // Sections go on the page (or in sections), never in the frame being pasted into. + let page = doc.page_of(parent).unwrap_or(parent); + let shift = match (at, clip.bounds) { + (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), + _ => Affine::IDENTITY, + }; + let mut out = Vec::new(); + let mut all = BTreeMap::new(); + for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } + let mut map = BTreeMap::new(); + for n in nodes { + map.insert(n.id, doc.alloc_id()); + } + let mut nodes = nodes.clone(); + let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); + let into = if section && !doc.takes_sections(parent) { page } else { parent }; + if let Some(r) = nodes.first_mut() { + r.transform = doc.world_transform(into).inverse() * shift * r.transform; + } + out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); + all.extend(map); + } + // Board connectors pasted with what they join join the pasted copies. + let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = + clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); + crate::board::rejoin(doc, &originals, &all, !copy_local); + if let Some(src) = foreign { + crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; + } + Ok(out) +} + +pub(super) fn paste(s: &mut Session, p: &Value) -> Result { + let page = s.page()?; + // Figma pastes into the selected frame (if one is selected), at the same position when it fits, + // else in the frame's centre; otherwise in place on the page (or the viewport centre). + let sel = s.selection(); + let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; + let target = id(p, "parent").or_else(|| { + sel.first() + .copied() + .filter(|i| { + sel.len() == 1 + && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) + }) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) + }); + let parent = target.unwrap_or(page); + let at = match (num(p, "x"), num(p, "y")) { + (Some(x), Some(y)) => Some((x, y)), + _ => { + let b = s.clipboard.bounds; + match (target, b) { + (Some(t), Some(b)) => { + let tb = doc.world_bounds(t); + match tb { + Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, + Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), + None => None, + } + } + (None, Some(b)) => { + // Off-screen originals paste at the viewport centre. + let v = &s.ds()?.view; + let (ox, oy) = v.origin(); + let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); + if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } + } + _ => None, + } + } + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_over(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc()?; + let (parent, at) = match sel.first() { + Some(i) => { + let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); + (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) + } + None => (page, None), + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_replace(s: &mut Session, p: &Value) -> Result { + let targets = req_targets("edit.pasteReplace", s, p)?; + let mut out = Vec::new(); + for t in targets { + let doc = s.doc()?; + let Some(n) = doc.node(t) else { continue }; + let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; + let idx = doc.index_in_parent(t); + let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); + let ids = paste_into(s, parent, at)?; + let doc = s.doc_mut()?; + for (k, i) in ids.iter().enumerate() { + if let Some(ix) = idx { + let _ = doc.reorder(*i, ix + k); + } + } + doc.remove(t)?; + out.extend(ids); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let mut v = json!({ + "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, + "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, + "cornerSmoothing": n.corners.smoothing, + }); + if let NodeKind::Text(t) = &n.kind { + v["fontFamily"] = json!(t.style.font_family); + v["fontStyle"] = json!(t.style.font_style); + v["fontSize"] = json!(t.style.font_size); + v["fontVariations"] = json!(t.style.variations); + v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); + v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); + } + v +} + +pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result { + let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; + let v = copy_props_of(s.doc()?, id); + s.clipboard.props = Some(v.clone()); + Ok(v) +} + +pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result { + let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; + let ids = req_targets("edit.pasteProperties", s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let mut m = props.as_object().cloned().unwrap_or_default(); + let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); + if !is_text { + m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); + } + if !doc.get(i)?.kind.has_corner_radius() { + m.remove("cornerRadius"); + m.remove("cornerSmoothing"); + } + crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?; + } + ok() +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs new file mode 100644 index 000000000..91855c2e8 --- /dev/null +++ b/crates/engine/src/cmd/edit/props.rs @@ -0,0 +1,461 @@ +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"; + let ids = req_targets(cmd, s, p)?; + let find = string(p, "find"); + let replace = string(p, "replace").unwrap_or_default(); + let pattern = string(p, "name"); + let rename = + crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) + .map_err(|e| bad(cmd, e))?; + let doc = s.doc_mut()?; + let names = rename.plan(doc, &ids); + for (i, name) in &names { + doc.get_mut(*i)?.name = name.clone(); + } + Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) +} + +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + +pub(super) fn find_replace(s: &mut Session, p: &Value) -> Result { + let find = req_string("edit.findReplace", p, "find")?; + if find.is_empty() { + return Err(bad("edit.findReplace", "`find` is empty")); + } + // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). + let replace = string(p, "replace").map(|r| crate::cmd::props::normalize_line_endings(&r)); + let names = boolean(p, "layerNames").unwrap_or(false); + let page = s.page()?; + let doc = s.doc_mut()?; + let mut hits = Vec::new(); + let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); + for i in doc.subtree(page) { + let Some(n) = doc.node(i) else { continue }; + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); + if !(text_hit || name_hit) || n.is_page() { + continue; + } + hits.push(i); + if let Some(r) = &replace { + let n = doc.get_mut(i)?; + if names && name_hit { + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; + } + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. + let mut cursor = 0; + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); + } + } + } + } + if boolean(p, "select").unwrap_or(true) { + s.set_selection(hits.clone()); + } + Ok(json!({"matches": hits})) +} + +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + +pub(super) fn set(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.set", s, p)?; + let props = match get(p, "props") { + Some(Value::Object(m)) => m.clone(), + _ => { + // Allow flat params: {"id": 3, "fill": "#f00"}. + let mut m = p.as_object().cloned().unwrap_or_default(); + m.remove("id"); + m.remove("ids"); + m + } + }; + if props.is_empty() { + return Err(bad("node.set", "no properties given")); + } + // A new rotation for the selection turns it about its moved rotation origin: each layer's + // point under the origin stays put. + let ds = s.ds()?; + let pivot = + ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); + let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { + Some(o) => roots(&ds.doc, &ids) + .into_iter() + .map(|i| (i, ds.doc.world_transform(i))) + .filter(|(_, w)| w.determinant().abs() > 1e-12) + .map(|(i, w)| (i, w.inverse() * o)) + .collect(), + None => Vec::new(), + }; + let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; + let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| crate::cmd::props::LAYOUT_KEYS.contains(k)).collect(); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; + } + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; + } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); + } + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; + } + crate::cmd::styles::detach_edited(doc, &before); + if let Some(o) = pivot { + for (i, q) in pinned { + let d = o - doc.world_transform(i) * q; + if d.hypot().is_finite() { + crate::cmd::arrange::move_world(doc, i, d.x, d.y)?; + } + } + } + if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } +} + +/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that +/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is +/// Bold), say which face it is. Families that aren't installed, and names that match a style or a +/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. +fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { + let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; + let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { + Some(f) => vec![f.to_string()], + None => ids + .iter() + .filter_map(|i| doc.node(*i)) + .filter_map(|n| n.kind.text()) + .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) + .collect(), + }; + families.sort(); + families.dedup(); + let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); + let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); + families + .into_iter() + .filter_map(|family| { + let styles = figcraft_text::family_styles(&family); + if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { + return None; + } + let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; + let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); + if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { + return None; + } + Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) + }) + .collect() +} + +/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances +/// record the override). +pub(super) fn width_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.widthProfile"; + let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); + let ids = req_targets(cmd, s, p)?; + let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); + let doc = s.doc_mut()?; + for i in ids { + crate::cmd::props::apply(cmd, doc, i, &props)?; + } + ok() +} + +pub(super) fn stroke_brush(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.brush"; + let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; + let brush = crate::cmd::props::brush_of(&v).ok_or_else(|| bad(cmd, crate::cmd::props::BRUSH_HELP))?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + if brush.is_some() && n.strokes.is_empty() { + n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); + } + n.stroke.brush = brush.clone(); + } + ok() +} + +pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.reparent", s, p)?; + let parent = req_id("node.reparent", p, "parent")?; + let index = num(p, "index").map(|i| i.max(0.0) as usize); + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); + for i in &ids { + // Counted with the layer still in place, as `reparent` expects. + let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); + doc.reparent(*i, parent, at)?; + } + ok() +} + +pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { + let cmd = if fills { "fill" } else { "stroke" }; + let ids = req_targets(cmd, s, p)?; + let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let frameish = n.kind.is_frame_like(); + let list = if fills { &mut n.fills } else { &mut n.strokes }; + if add { + let paint = match get(p, "paint").or_else(|| get(p, "color")) { + Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, + None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), + None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), + None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), + }; + list.push(paint); + } else { + let idx = index.unwrap_or(list.len().saturating_sub(1)); + // The paint's colour binding goes with it; later paints' move down one. + crate::cmd::lists::remove_item(n, if fills { crate::cmd::lists::BoundList::Fills } else { crate::cmd::lists::BoundList::Strokes }, idx); + } + } + ok() +} + +pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.set" } else { "stroke.set" }; + let ids = req_targets(cmd, s, p)?; + let idx = opt_index(cmd, p, "index")?; + let new_paint = match get(p, "paint") { + Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), + None => None, + }; + let col = color(p, "color"); + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let list = if fills { &mut n.fills } else { &mut n.strokes }; + let at = idx.unwrap_or(list.len().saturating_sub(1)); + if list.is_empty() { + list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); + } + let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; + let old = slot.clone(); + if let Some(np) = &new_paint { + *slot = np.clone(); + } + if let Some(c) = col { + slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; + if c.a < 1.0 { + slot.opacity = c.a; + slot.color_alpha = c.a; + } + } + if let Some(o) = num(p, "opacity") { + slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; + } + if let Some(v) = boolean(p, "visible") { + slot.visible = v; + } + if let Some(b) = blend(p, "blend") { + slot.blend = b; + } + crate::cmd::lists::rebind_edited_paint(n, fills, at, &old); + if fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + } + ok() +} + +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + // A copied pattern pointing at a target or a layer inside it would make the layer pattern + // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. + if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { + let doc = s.doc()?; + if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { + return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); + } + } + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + +pub(super) fn pattern_fill(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "fill.pattern"; + let src = req_id(CMD, p, "source")?; + let doc = s.doc()?; + if doc.node(src).is_none_or(|n| n.is_page()) { + return Err(bad(CMD, "`source` must be a layer")); + } + if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { + return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); + } + let mut pt = figcraft_color::PatternPaint::new(src.0); + pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); + pt.align = parse(CMD, p, "align")?.unwrap_or_default(); + pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); + let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; + pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["paint"] = + serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) + .map_err(|e| bad(CMD, e.to_string()))?; + paint_set(s, &q, true) +} + +pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result { + let e: Effect = match crate::cmd::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, + None => { + let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); + Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? + } + }; + for i in req_targets("effect.add", s, p)? { + s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); + } + ok() +} + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +pub(super) fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +pub(super) fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs new file mode 100644 index 000000000..ca1da3bbc --- /dev/null +++ b/crates/engine/src/cmd/edit/select.rs @@ -0,0 +1,269 @@ +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")?); + let doc = s.doc()?; + let zoom = s.ds()?.view.zoom.max(1e-6); + let sel = s.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) + .into_iter() + .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) + .collect(); + Ok(json!({"layers": layers})) +} + +pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + // Select all siblings of the current selection, else the page's top level. + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// What Select all would select, less the selection (hidden and locked layers neither). +pub(super) fn select_inverse(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = + doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. +pub(super) fn select_area(s: &mut Session, p: &Value) -> Result { + let cmd = "select.area"; + let keys = ["x", "y", "width", "height"]; + if keys.iter().all(|k| p.get(*k).is_none()) { + s.request_ui("selectArea", json!({})); + return ok(); + } + let [x, y, w, h] = keys.map(|k| num(p, k)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; + if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return Err(bad(cmd, "the box is out of range")); + } + let start = figcraft_geom::Point::new(x, y); + let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); + let add = boolean(p, "add") == Some(true); + let base = if add { s.selection() } else { Vec::new() }; + let within = s.marquee_within(start)?; + let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; + s.set_selection(sel.clone()); + Ok(json!({"selection": sel})) +} + +pub(super) fn select_children(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids = Vec::new(); + for i in s.selection() { + if let Some(n) = doc.node(i) { + ids.extend(n.children.iter().copied()); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_parent(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids: Vec = Vec::new(); + for i in s.selection() { + if let Some(p) = doc.node(i).and_then(|n| n.parent) + && !doc.node(p).is_some_and(Node::is_page) + && !ids.contains(&p) + { + ids.push(p); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_sibling(s: &mut Session, dir: i64) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(cur) = sel.last().copied() else { + let first = doc.get(s.page()?)?.children.last().copied(); + s.set_selection(first.into_iter().collect()); + return Ok(json!({"selection": s.selection()})); + }; + let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; + let kids = &doc.get(parent)?.children; + let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; + let n = kids.len() as i64; + // Layers panel order is reversed (top of the list = last child): Tab goes down the list. + let next = (idx - dir).rem_euclid(n.max(1)) as usize; + let id = kids.get(next).copied().unwrap_or(cur); + s.set_selection(vec![id]); + Ok(json!({"selection": [id]})) +} + +pub(super) fn select_matching(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; + let (name, ty) = (first.name.clone(), first.kind.type_name()); + let page = s.page()?; + let ids: Vec = + doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; + let page = s.page()?; + let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in +/// quick succession the exact percentage. +pub fn opacity_from_digits(digits: &str) -> Option { + let mut n = 0u32; + let mut count = 0; + for c in digits.chars() { + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; + count += 1; + } + match count { + 1 if n == 0 => Some(1.0), + 1 => Some(n as f32 / 10.0), + 2 => Some(n as f32 / 100.0), + _ => None, + } +} + +pub(super) fn opacity_keys(s: &mut Session, p: &Value) -> Result { + let digits = req_string("object.opacityKeys", p, "digits")?; + let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; + } + crate::cmd::styles::detach_edited(doc, &before); + Ok(json!({"opacity": o})) +} + +pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + let (dx, dy) = (ux * k, uy * k); + let ids = req_targets("transform.nudge", s, p)?; + // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds + // no undo step. + let mut work = s.doc()?.clone(); + let doc = &mut work; + let ids = roots(doc, &ids); + let mut changed = false; + // Children in an auto layout flow move one place instead (see `al_keys`). + for i in &ids { + if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { + let n = doc.get_mut(*i)?; + n.transform = Affine::translate((dx, dy)) * n.transform; + changed = true; + } + } + for dir in crate::al_keys::Dir::from_units(ux, uy) { + // Grouped per arrow: a move into a neighbouring frame changes the parent. + let mut groups: BTreeMap> = BTreeMap::new(); + for i in &ids { + if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { + groups.entry(parent).or_default().push(*i); + } + } + for (parent, block) in groups { + changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; + } + } + if changed { + s.ds_mut()?.doc = std::sync::Arc::new(work); + } + ok() +} + +pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); + let ids = req_targets("transform.nudgeSize", s, p)?; + let ids = roots(s.doc()?, &ids); + for i in ids { + let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; + let mut q = serde_json::Map::new(); + q.insert("ids".into(), json!([i])); + // Only the axis that changes becomes fixed. + if dw != 0.0 { + q.insert("width".into(), json!((w + dw).max(0.0))); + } + if dh != 0.0 { + q.insert("height".into(), json!((h + dh).max(0.0))); + } + crate::cmd::arrange::resize_cmd(s, &Value::Object(q))?; + } + ok() +} + +pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { + let ids = req_targets("object.toggle", s, p)?; + let doc = s.doc_mut()?; + let explicit = boolean(p, what); + // Figma: if any is visible, hide all (and vice versa). + let value = explicit.unwrap_or_else(|| match what { + "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), + _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), + }); + for i in &ids { + let n = doc.get_mut(*i)?; + if what == "visible" { + n.visible = value; + } else { + n.locked = value; + } + } + if what == "locked" && value { + s.set_selection(vec![]); + } + Ok(json!({what: value})) +} + +pub(super) fn hide_others(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc_mut()?; + let keep: BTreeSet = sel + .iter() + .flat_map(|i| { + let mut v = doc.ancestors(*i); + v.push(*i); + v + }) + .collect(); + let tops: Vec = doc.get(page)?.children.clone(); + for t in tops { + if !keep.contains(&t) { + doc.get_mut(t)?.visible = false; + } + } + ok() +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 13eb21d62..7c9012933 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,14 +3,47 @@ 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; +mod load; +mod open; +mod save; +mod version; + +pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS; +#[cfg(test)] +use export::add_member; +pub(super) use export::export_setting; +pub use export::{ + ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, + frames_pdf, jobs_for, safe_file_name, +}; +use export::{export, export_frames_pdf, export_layers, pdf_bytes}; +#[cfg(test)] +pub(crate) use load::{installed_family, sketch_line}; +pub use load::{load, load_bytes, load_path}; +pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; +pub use open::MAX_RECENT; +#[cfg(test)] +use open::append_pages; +use open::{import, open, open_recent}; +pub(crate) use save::save_ends_preview; +pub use save::{FileSnapshot, file_bytes, thumbnail}; +use save::{color_profile, mark_saved, save, save_as}; +pub use version::MAX_AUTOSAVES; +pub(crate) use version::{add_version, keep_in_history, read_version, unique_id}; +use version::{autosave, restore_version, save_version, version_index, version_json, version_list}; + /// A new file of `kind` in a tab of its own. fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); @@ -295,987 +328,6 @@ pub fn specs() -> Vec { ] } -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}); - if v.auto { - j["auto"] = json!(true); - } - j -} - -/// Autosaves kept per file; older ones make room for new ones. -pub const MAX_AUTOSAVES: usize = 20; - -/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. -fn autosave(s: &mut Session, _: &Value) -> Result { - let auto_save = s.prefs.auto_save; - let ds = s.ds_mut()?; - // A file that saves itself is rarely unsaved: edits since it was opened count then. - let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); - if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { - return Ok(json!({"saved": false})); - } - Ok(match add_version(ds, AUTOSAVE, "", true) { - Some(v) => json!({"saved": true, "version": v}), - None => json!({"saved": false}), - }) -} - -/// What checkpoints are called. -const AUTOSAVE: &str = "Autosave"; - -/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest -/// autosave makes room when the autosaves or the history are full; versions the user named are -/// never dropped, so a history full of them takes nothing (None). -pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { - let autos = ds.versions.iter().filter(|v| v.auto).count(); - if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) - && let Some(oldest) = ds.versions.iter().position(|v| v.auto) - { - ds.versions_mut().remove(oldest); - } - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return None; - } - let mut v = snapshot_version(ds, name, description); - v.auto = auto; - let out = version_json(ds.versions.len(), &v); - ds.versions_mut().push(v); - ds.mark_checkpoint(); - Some(out) -} - -/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless -/// it is what the last version saved or restored holds. -pub(crate) fn keep_in_history(ds: &mut DocState) { - if ds.branch_of.is_none() && ds.changed_since_checkpoint() { - add_version(ds, AUTOSAVE, "", true); - } -} - -/// A version of `ds`'s document as it is now, opening on the page shown. -fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { - let mut snap = ds.doc.clone(); - if snap.current_page != Some(ds.page) { - Arc::make_mut(&mut snap).current_page = Some(ds.page); - } - Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, super::comment::now_secs(), snap) -} - -fn version_list(ds: &DocState) -> Value { - let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); - Value::Array( - ds.versions - .iter() - .enumerate() - .map(|(i, v)| { - let mut j = version_json(i, v); - if previewing == Some(v.id.as_str()) { - j["previewing"] = json!(true); - } - j - }) - .collect(), - ) -} - -/// The version named by `id`, else by `index` (validated: never negative, never past the end). -fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { - if let Some(id) = string(p, "id") { - return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); - } - if get(p, "index").is_none() { - return Err(bad(cmd, "pass the version's `id` or `index`")); - } - let i = req_index(cmd, p, "index")?; - if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } -} - -pub(crate) fn read_version(v: &Version) -> Result> { - v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) -} - -/// A new version or branch id: the time in milliseconds (hex), not `taken`. -pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { - let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); - loop { - let id = format!("{n:x}"); - if !taken(&id) { - return id; - } - n = n.wrapping_add(1); - } -} - -/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. -fn save_version(s: &mut Session, p: &Value) -> Result { - let ds = s.ds_mut()?; - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return Err(EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - )); - } - let name = - string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); - let description = string(p, "description").unwrap_or_default(); - // The version opens on the page shown when it was saved. - add_version(ds, &name, description.trim(), false).ok_or_else(|| { - EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - ) - }) -} - -fn restore_version(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "version.restore"; - // Refused before anything goes into the history. - if let Some(why) = s.live_read_only(s.ds()?.uid) { - return Err(EngineError::Disabled(CMD.into(), why.into())); - } - let ds = s.ds_mut()?; - let i = version_index(CMD, ds, p)?; - let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; - let (id, mut doc) = (v.id.clone(), read_version(v)?); - // Restoring never loses work: the document as it is goes into the history first. - keep_in_history(ds); - let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); - let d = Arc::make_mut(&mut doc); - d.name = ds.doc.name.clone(); - // What the file published belongs to the file, not to the version. - d.published = ds.doc.published.clone(); - // Comments are a conversation about the file, kept apart from its design history: they stay, - // and those on layers the version doesn't have stay where they were. - d.comments = ds.doc.comments.clone(); - super::comment::follow_anchors(d, &ds.doc); - // The snapshot is already consistent: nothing to re-normalise. - d.take_dirty(); - // Stay on the current page when the version has it. - ds.page = Some(ds.page) - .filter(|pg| d.pages.contains(pg)) - .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) - .or_else(|| d.first_page()) - .unwrap_or(ds.page); - ds.doc = doc; - ds.selection.clear(); - ds.text_edit = None; - ds.vector_edit = None; - // The restored design is in the history already: restoring it again keeps nothing new. - ds.mark_checkpoint(); - Ok(out) -} - -/// The recovery copy at `path` as a document with unsaved changes and no path. -pub(crate) fn recovered(path: &str) -> Result { - let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; - Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn read(path: &str) -> Result> { - crate::file_access::read(path) -} - -#[cfg(target_arch = "wasm32")] -fn read(path: &str) -> Result> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -/// The bytes of file `path`, refused when it's larger than `max`. -pub(crate) fn read_capped(path: &str, max: u64) -> Result> { - crate::file_access::read_capped(path, max) -} - -/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one -/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never -/// truncates `path`, and no file or symlink at a staging name is ever written through. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - crate::file_access::write(path, bytes) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -fn ext(path: &str) -> String { - std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() -} - -fn file_stem(path: &str) -> String { - std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() -} - -/// The counts the import report shows for a freshly loaded document. -fn import_summary(doc: &Document) -> Value { - json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) -} - -/// Load a document from any supported file (REST API file JSON images come from an `images` -/// folder next to the file). -pub fn load(path: &str) -> Result<(Document, Vec)> { - load_path(path, None).map(|(l, _)| (l.doc, l.issues)) -} - -pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { - load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) -} - -/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST -/// import reads its variables from `variables`, else from a variables JSON next to the file. -pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { - let bytes = read(path)?; - let sibling = if variables.is_none() { sibling_variables(path) } else { None }; - load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) -} - -/// Load from bytes. Returns the document with its notes (repairs of a native file, or import -/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` -/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. -fn load_with( - bytes: &[u8], - name_hint: &str, - variables: Option<&Value>, - images: figcraft_format::rest::ImageResolver, -) -> Result<(figcraft_format::Loaded, bool)> { - match ext(name_hint).as_str() { - "svg" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) - .map_err(|e| EngineError::Other(e.to_string()))? - .notes; - Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) - } - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; - let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; - doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - Ok((doc.into(), false)) - } - _ => { - if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); - } - let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - // Older files saved an untouched default title even after the user chose a filename. - if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { - loaded.doc.name = file_stem(name_hint); - } - Ok((loaded, false)) - } - } -} - -/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. -fn load_sketch(bytes: &[u8], name: &str) -> Option> { - if !figcraft_format::sketch::is_sketch(bytes) { - return None; - } - // The import and what follows read the installed fonts: the system's must be known first (the - // desktop app scans them in the background; this waits for that scan). - figcraft_text::fontdb::scan_system_fonts(); - let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); - let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - match_font_families(&mut doc); - platform_tracking(&mut doc); - sketch_line_heights(&mut doc); - link_instances(&mut doc); - reflow_overridden_text(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Instances of a stack sized apart from their main are laid out at their size, and texts in -/// instances whose string an override changed are measured again with the auto layout around them -/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout -/// Sketch saved. -fn reflow_overridden_text(doc: &mut Document) { - let changed: Vec = doc - .nodes() - .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { - (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, - _ => false, - }) - .map(|n| n.id) - .collect(); - let m = crate::measure::TextMeasure; - // Instances of a stack at another size than their main: the stack at their size, innermost first. - let mut resized: Vec<(usize, NodeId)> = doc - .nodes() - .filter(|n| n.layout.is_some()) - .filter(|n| match &n.kind { - NodeKind::Instance { component, .. } => { - doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) - } - _ => false, - }) - .map(|n| (doc.ancestors(n.id).len(), n.id)) - .collect(); - resized.sort_by_key(|r| std::cmp::Reverse(r.0)); - for (_, id) in resized { - figcraft_layout::layout_frame(doc, id, &m); - } - for id in changed { - if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { - figcraft_layout::set_size(doc, id, w, h); - } - figcraft_layout::layout_up(doc, id, &m); - } -} - -/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from -/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and -/// case. -fn match_font_families(doc: &mut Document) { - let installed = figcraft_text::fontdb::family_names(); - let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - let mut fix = |style: &mut figcraft_doc::TextStyle| { - let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); - if let Some(f) = found { - style.font_family = f.clone(); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - fix(&mut td.style); - for r in &mut td.runs { - fix(&mut r.style); - } - } - } - // Shared text styles too, so they stay like the layers linked to them. - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - fix(style); - } - } -} - -/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine -/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so -/// lines measure and break as they did there. A named style's optical size follows the text size -/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. -fn platform_tracking(doc: &mut Document) { - let mut cache: std::collections::BTreeMap<(String, String), Option>> = - std::collections::BTreeMap::new(); - let mut face = |style: &figcraft_doc::TextStyle| { - cache - .entry((style.font_family.clone(), style.font_style.clone())) - .or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - }) - .clone() - }; - let mut track = |style: &mut figcraft_doc::TextStyle| { - let Some(f) = face(style) else { return }; - let t = f.tracking(style.font_size); - if t != 0.0 && t.is_finite() { - let px = style.letter_spacing.resolve(style.font_size); - style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); - } - if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { - style.variations.entry("opsz".into()).or_insert(style.font_size); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - track(&mut td.style); - for r in &mut td.runs { - track(&mut r.style); - } - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - track(style); - } - } -} - -/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their -/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. -const TALL_LINE_FAMILIES: [&str; 11] = - ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; - -/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its -/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch -/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth -/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres -/// the font's own ascent and descent in the line. `(0, 0)` without metrics. -pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { - let k = size / upem; - let (a, d) = (ascent * k, descent * k); - if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { - return (0.0, 0.0); - } - let natural = a.round() + d.round(); - let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; - let sketch_baseline = line - d.round(); - let figcraft_baseline = (line - (a + d)) / 2.0 + a; - (line, sketch_baseline - figcraft_baseline) -} - -/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, -/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a -/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text -/// styles take the line too. -fn sketch_line_heights(doc: &mut Document) { - // Per family and style: the face's ascent, descent and units per em. - type Metrics = Option<(f64, f64, f64)>; - let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); - let mut set = |style: &mut TextStyle| -> f64 { - if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { - return 0.0; - } - let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - .map(|f| (f.ascent, f.descent, f.upem)) - }); - let Some((a, d, upem)) = m else { return 0.0 }; - let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); - if line > 0.0 { - style.line_height = LineHeight::Percent(line / style.font_size * 100.0); - } - down - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - // An auto layout places its texts itself (its next pass would take a move back). - let laid_out = - doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let Some(n) = doc.node_mut(id) else { continue }; - let NodeKind::Text(td) = &mut n.kind else { continue }; - let down = set(&mut td.style); - for r in &mut td.runs { - set(&mut r.style); - } - // Down in the layer's own frame (a rotated text moves along its own axis). - if !laid_out && down.is_finite() && down != 0.0 { - n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - set(style); - } - } -} - -/// The installed family `family` means when it isn't installed under that exact name: the one -/// whose name is the same without spaces, ignoring case. -pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { - let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); - if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { - return None; - } - let want = key(family); - installed.iter().find(|f| key(f) == want).cloned() -} - -/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. -pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { - let r = figcraft_format::fig::try_import(bytes, name)?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - link_instances(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Link imported instances to their mains (layer sources, overrides found by comparing values), -/// innermost first so outer instances settle last. -fn link_instances(doc: &mut Document) { - super::styles::unfold_imported_bound_alpha(doc); - crate::style_vars::after_load(doc); - super::styles::apply_paint_bindings_on_load(doc); - let mut insts: Vec<(usize, NodeId)> = - doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); - insts.sort_by_key(|a| std::cmp::Reverse(a.0)); - for (_, i) in insts { - crate::components::rebase_instance(doc, i); - } -} - -/// Largest image file read for a REST API file JSON import. -#[cfg(not(target_arch = "wasm32"))] -const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; - -/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to -/// the file. -#[cfg(not(target_arch = "wasm32"))] -fn rest_images(path: &str) -> impl Fn(&str) -> Option> { - let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); - move |r: &str| { - // Image refs are content hashes; anything else never becomes a path. - if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { - return None; - } - let dir = dir.as_ref()?; - ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] - .iter() - .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) - } -} - -#[cfg(target_arch = "wasm32")] -fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { - figcraft_format::rest::no_images -} - -/// Largest variables JSON read from disk. -#[cfg(not(target_arch = "wasm32"))] -const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; - -/// A local variables JSON next to a REST API file JSON: `.variables.json`, else -/// `variables.json`. -#[cfg(not(target_arch = "wasm32"))] -fn sibling_variables(path: &str) -> Option { - if ext(path) != "json" { - return None; - } - let file = std::path::Path::new(path); - let dir = file.parent()?; - [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { - let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; - figcraft_format::rest::is_variables_json(&v).then_some(v) - }) -} - -#[cfg(target_arch = "wasm32")] -fn sibling_variables(_path: &str) -> Option { - None -} - -/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. -pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { - let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); - let v = match get(p, "variables") { - None | Some(Value::Null) => return Ok(None), - Some(v @ Value::Object(_)) => v.clone(), - Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, - Some(Value::String(path)) => parse(&read(path)?)?, - Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), - }; - if !figcraft_format::rest::is_variables_json(&v) { - return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); - } - Ok(Some(v)) -} - -/// How many files File ▸ Open recent keeps. -pub const MAX_RECENT: usize = 20; - -/// Put `path` at the top of the recent files. -fn remember(s: &mut Session, path: &str) { - let r = &mut s.prefs.recent_files; - r.retain(|p| p != path); - r.insert(0, path.to_string()); - r.truncate(MAX_RECENT); -} - -fn open_recent(s: &mut Session, p: &Value) -> Result { - use crate::file_access::{Access, allow, allowed}; - const CMD: &str = "file.openRecent"; - // Confined to automation roots, the recent files outside them are never listed, opened, removed - // or named: `index` counts the files listed. - let listed = |r: &String| allowed(r, Access::Read); - if p.get("clear").and_then(Value::as_bool) == Some(true) { - s.prefs.recent_files.retain(|r| !listed(r)); - } - if let Some(gone) = string(p, "remove") { - s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); - } - let path = match (string(p, "path"), num(p, "index")) { - (Some(path), _) => { - allow(&path, Access::Read)?; - Some(path) - } - (None, Some(i)) => { - Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) - } - _ => None, - }; - if let Some(path) = path { - if !crate::file_access::exists(&path) { - s.prefs.recent_files.retain(|r| *r != path); - return Err(bad(CMD, format!("{path} no longer exists"))); - } - return open(s, &json!({"path": path})); - } - let files: Vec = s - .prefs - .recent_files - .iter() - .filter(|r| listed(r)) - .map(|r| { - let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); - json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) - }) - .collect(); - Ok(json!({"files": files})) -} - -fn open(s: &mut Session, p: &Value) -> Result { - let variables = variables_param("file.open", p)?; - if string(p, "path").is_none() - && let Some(d) = string(p, "data") - { - return open_data(s, &d, p, variables.as_ref()); - } - let path = req_string("file.open", p, "path")?; - // Already open: switch to it. - if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { - s.active = i; - remember(s, &path); - return Ok(json!({"index": i})); - } - // Stamped before reading: a change made during the read makes the next save refuse. - let on_disk = crate::library::stat(&path).ok(); - let (loaded, rest) = load_path(&path, variables.as_ref())?; - remember(s, &path); - let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); - let warnings = rest || ext(&path) == "svg"; - // A native file that lost something (a damaged image, say): the app says so, not only the result. - if native && !loaded.issues.is_empty() { - s.message = Some(match loaded.issues.as_slice() { - [one] => format!("{}: {one}", file_stem(&path)), - more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), - }); - } - let summary = import_summary(&loaded.doc); - let images_left_out = if native { loaded.images_past_budget } else { 0 }; - let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, images_left_out); - ds.on_disk = on_disk.filter(|_| native); - s.docs.push(ds); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells -/// the format. It opens as a new document with no path, so the first save asks where to put it. -fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { - let name = req_string("file.open", p, "name")?; - let data = data.split_once(',').map_or(data, |(_, b)| b); - let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; - let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; - let warnings = rest || ext(&name) == "svg"; - let summary = import_summary(&loaded.doc); - s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// Thumbnail PNG of the first page's content (for file browsers / the home screen). -pub fn thumbnail(doc: &Document) -> Option> { - let page = doc.first_page()?; - let b = doc.page_content_bounds(page)?; - let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { - origin: (b.x0, b.y0), - scale, - width: (b.width() * scale).ceil().max(1.0) as u32, - height: (b.height() * scale).ceil().max(1.0) as u32, - }; - let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; - figcraft_render::encode_png(&pix).ok() -} - -/// The `.figcraft` file for a document: content, thumbnail and version history. -pub fn file_bytes(ds: &DocState) -> Result> { - FileSnapshot::of(ds).encode() -} - -/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the -/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then -/// [`save`](Self::save) the bytes back on the session's thread. -pub struct FileSnapshot { - uid: u64, - path: Option, - revision: u64, - doc: Arc, - /// The document as last saved when the snapshot was taken: another save since makes it stale. - saved: Arc, - page: NodeId, - versions: Vec, - branches: Vec, -} - -impl FileSnapshot { - pub fn of(ds: &DocState) -> Self { - Self { - uid: ds.uid, - path: ds.path.clone(), - revision: ds.revision, - doc: ds.doc.clone(), - saved: ds.saved_doc.clone(), - page: ds.page, - versions: ds.versions.clone(), - branches: ds.branches.clone(), - } - } - - /// The file's bytes: content, thumbnail and version history. - pub fn encode(&self) -> Result> { - let mut doc = (*self.doc).clone(); - doc.current_page = Some(self.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) - .map_err(|e| EngineError::Other(e.to_string())) - } - - /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved - /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document - /// was closed, saved, reverted or given another path since: the bytes are out of date. - pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { - let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { - return Ok(false); - }; - let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; - refuse_dropping_images(ds, path)?; - refuse_overwriting_changes(ds, path)?; - write(path, bytes)?; - ds.note_on_disk(); - // The history or branches may have changed since: then they still count as unsaved. - if ds.revision == self.revision { - ds.versions_changed = false; - } - ds.saved_doc = self.doc; - ds.revision += 1; - branch_tabs_saved(s, self.uid); - Ok(true) - } -} - -fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { - match ds.images_lost_saving_to(path) { - Some(count) => Err(EngineError::Other(format!( - "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", - file_stem(path) - ))), - None => Ok(()), - } -} - -/// Saving over the tab's own file would lose what another app wrote to it since (#1330). -fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { - match (&ds.on_disk, crate::library::stat(path)) { - (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( - "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", - file_stem(path) - ))), - _ => Ok(()), - } -} - -/// The branch tabs of the file in tab `uid` were saved with it. -fn branch_tabs_saved(s: &mut Session, uid: u64) { - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } -} - -fn color_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "file.colorProfile"; - let Some(name) = string(p, "profile") else { - s.request_ui("dialog.colorProfile", json!({})); - return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); - }; - let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; - let convert = boolean(p, "convert").unwrap_or(true); - let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); - Ok(json!({"profile": profile.id(), "changed": changed})) -} - -/// Native saves act on the current document; a `.fig` copy exports the version shown, and a -/// pending Save As choice must leave it shown until its destination is known. -pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { - let path = - string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) -} - -/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). -fn wants_data(p: &Value) -> bool { - p.get("data").and_then(Value::as_bool) == Some(true) -} - -/// Save the file of the active tab (the main file for a branch tab, with its branches), or export -/// the active document as an experimental `.fig` copy without changing its native save state. -/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). -fn save_to(s: &mut Session, path: Option) -> Result { - if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { - return export_fig(s, &json!({"path": path})); - } - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - // A download (no path) writes over no file. - if let Some(path) = path.as_deref() { - refuse_dropping_images(ds, path)?; - } - let mut snapshot = FileSnapshot::of(ds); - let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if let Some(path) = path.as_deref() - && follows_path - && ds.path.as_deref() != Some(path) - { - Arc::make_mut(&mut snapshot.doc).name = file_stem(path); - } - let bytes = snapshot.encode()?; - let Some(path) = path else { - // The download can still be refused: the caller marks the file saved once it was delivered. - let name = format!("{}.figcraft", ds.doc.name); - return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); - }; - write(&path, &bytes)?; - ds.doc = snapshot.doc; - ds.path = Some(path.clone()); - ds.note_on_disk(); - mark_saved(s, &json!({}))?; - remember(s, &path); - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -/// The file of the active tab (with its branch tabs) now matches what was written or delivered. -fn mark_saved(s: &mut Session, _: &Value) -> Result { - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); - Ok(json!({"saved": true})) -} - -fn save(s: &mut Session, p: &Value) -> Result { - if let Some(path) = string(p, "path") { - return save_to(s, Some(path)); - } - let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; - match ds.path.clone() { - // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. - Some(path) if ext(&path) == "fig" => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) - } - Some(path) => { - if p.get("overwrite").and_then(Value::as_bool) != Some(true) { - refuse_overwriting_changes(ds, &path)?; - } - save_to(s, Some(path)) - } - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) - } - } -} - -fn save_as(s: &mut Session, p: &Value) -> Result { - match string(p, "path") { - Some(mut path) => { - if ext(&path).is_empty() { - path.push_str(".figcraft"); - } - save_to(s, Some(path)) - } - None if wants_data(p) => save_to(s, None), - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) - } - } -} - -/// Encode `ids` (or the page) in `format` at `scale`. -pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { - export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) -} - -/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, -/// so they take no room next to visible ones (a hidden-only export keeps its own area). -pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { - let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); - ids.iter() - .filter_map(|i| { - let node = doc.node(*i); - let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { - doc.world_bounds(*i) - } else if any_visible && node.is_some_and(|n| !n.visible) { - None - } else { - figcraft_render::export_bounds(doc, *i) - } - }) - .reduce(|a, b| a.union(b)) -} - -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) -/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing -/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a -/// slice anywhere else draws every top-level layer of its page. -pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { - return super::edit::z_sorted(doc, ids); - }; - if !setting.contents_only { - return page.children.clone(); - } - let mut own = Vec::with_capacity(ids.len()); - for &i in ids { - if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { - own.push(i); - continue; - } - match slice_container(doc, i) { - Some(c) => own.push(c), - None => return page.children.clone(), - } - } - own.sort_unstable(); - own.dedup(); - super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) -} - -/// The frame or group a slice sits in: None on the page or in a section. -fn slice_container(doc: &Document, slice: NodeId) -> Option { - let parent = doc.node(slice)?.parent?; - doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) -} - -/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see -/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. -pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { - export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) -} - /// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only; /// empty for the other formats). pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec, Vec)> { @@ -1336,112 +388,6 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal } } -/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour -/// profile; the clipboard wants sRGB). -pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { - let area = export_area(doc, ids, &ExportSetting::default())?; - figcraft_render::render_area(doc, &super::edit::z_sorted(doc, ids), area, 2.0) -} - -fn export(s: &mut Session, p: &Value) -> Result { - let cmd = "file.export"; - let fig = match string(p, "format") { - Some(f) => f.eq_ignore_ascii_case("fig"), - None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), - }; - if fig { - return export_fig(s, p); - } - let doc = s.doc()?; - let mut ids = targets(s, p); - if ids.is_empty() { - ids = doc.get(s.page()?)?.children.clone(); - } - let mut setting = export_setting(cmd, ExportSetting::default(), p)?; - if string(p, "format").is_none() - && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) - { - setting.format = f; - } - let scale = match size_param(cmd, p)? { - Some(_) => export_scale(doc, &ids, &setting), - None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), - }; - let format = setting.format; - let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; - let mut out = match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) - } - None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), - }; - // Android Vector Drawables say what they changed or left out (an empty list when nothing). - if format == ExportFormat::VectorDrawable { - out["warnings"] = json!(warnings); - } - Ok(out) -} - -/// The whole document as a `.fig` file (experimental: only round trips through our own importer -/// are verified), with the warnings about what was left out. -fn export_fig(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let (bytes, warnings) = fig_bytes(doc)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) - } - None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), - } -} - -/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. -pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { - figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) -} - -/// The scale that gives `ids` (their export area together) `setting`'s export size. -pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { - // Size-based exports ask for bounds before encoding. Let encoding report the depth error - // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. - if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { - return 1.0; - } - export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) -} - -/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. -pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { - if let Some(size) = size_param(cmd, p)? { - e.size = size; - } - if let Some(s) = string(p, "suffix") { - e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); - } - if let Some(f) = string(p, "format") { - e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; - } - if let Some(c) = vd_compat_param(cmd, p)? { - e.vd_compat = c; - } - e.print = print_param(cmd, p, e.print)?; - for (key, slot) in [ - ("contentsOnly", &mut e.contents_only), - ("includeBoundingBox", &mut e.include_bounding_box), - ("svgOutlineText", &mut e.svg_outline_text), - ("svgIdAttribute", &mut e.svg_id_attribute), - ("svgSimplifyStroke", &mut e.svg_simplify_stroke), - ] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(e) -} - /// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). fn vd_compat_param(cmd: &str, p: &Value) -> Result> { match get(p, "vdCompat") { @@ -1450,345 +396,6 @@ fn vd_compat_param(cmd: &str, p: &Value) -> Result> { } } -/// Whether `p` sets any PDF print option. -fn has_print_param(p: &Value) -> bool { - ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) -} - -/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or -/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. -pub(super) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { - match get(p, "bleed") { - None | Some(Value::Null) => {} - Some(v) => { - let pt = match v { - Value::String(s) => PrintSettings::parse_length(s), - v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), - }; - base.bleed = pt - .filter(|b| *b <= PrintSettings::MAX_BLEED) - .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; - } - } - for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(base) -} - -/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. -fn size_param(cmd: &str, p: &Value) -> Result> { - let size = match get(p, "size") { - None | Some(Value::Null) => return Ok(None), - Some(Value::String(s)) => ExportSize::parse(s), - Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), - Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), - }; - size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) -} - -/// Most files one `file.exportLayers` writes. -const MAX_EXPORT_FILES: usize = 1000; -/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy -/// are all in memory at once (in the browser too). -const MAX_ZIP_BYTES: usize = 256 << 20; - -/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. -fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { - let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); - if total > limit { - let mb = limit >> 20; - return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); - } - members.push((name, bytes)); - Ok(()) -} - -/// One file of a layer export: the layer, its setting and the file name. -pub struct ExportJob { - pub id: NodeId, - pub setting: ExportSetting, - pub file: String, -} - -/// The files exporting `ids` with their export settings makes, with unique names (repeats get -/// " 2", " 3"…). Layers without settings make none. -pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { - jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) -} - -/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). -pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { - let mut used = std::collections::BTreeSet::new(); - let mut out = Vec::new(); - for (id, settings) in items { - let Some(n) = doc.node(id) else { continue }; - let base = export_path(&n.name); - for e in settings { - if out.len() >= MAX_EXPORT_FILES { - return out; - } - let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); - let ext = e.format.ext(); - let file = - (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); - if let Some(file) = file { - out.push(ExportJob { id, setting: e, file }); - } - } - } - out -} - -/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and -/// `..` segments are dropped, so it stays inside the export folder. -fn export_path(name: &str) -> String { - let parts: Vec = - name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); - if parts.is_empty() { "Untitled".into() } else { parts.join("/") } -} - -/// Characters no file name may contain (path separators and ones file systems reject). -pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; - -/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and -/// control characters become `_`); everything else is kept, up to the first 200 characters. -/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before -/// the number and extension it adds). -pub fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() -} - -/// Layers on `page` that have export settings (document order). -pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { - doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() -} - -fn export_layers(s: &mut Session, p: &Value) -> Result { - let cmd = "file.exportLayers"; - let doc = s.doc()?; - let assets = boolean(p, "assets").unwrap_or(false); - let mut ids = targets(s, p); - if ids.is_empty() { - ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; - } - let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; - if jobs.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); - } - let dir = string(p, "dir"); - // The browser saves one download at a time: several files go as one zip (#969). - let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); - let mut members = Vec::new(); - // Print options passed here stand in for every PDF setting's own. - let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; - // And a compatibility mode for every Android Vector Drawable. - let compat = vd_compat_param(cmd, p)?; - let mut files = Vec::with_capacity(jobs.len()); - for job in &jobs { - let setting = - ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; - let scale = export_scale(doc, &[job.id], &setting); - let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; - let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); - if setting.format == ExportFormat::VectorDrawable { - f["warnings"] = json!(warnings); - } - match &dir { - Some(d) => { - let path = std::path::Path::new(d).join(&job.file); - // A layer name with slashes exports into folders. - if let Some(parent) = path.parent() { - let _ = crate::file_access::create_dir_all(parent); - } - let path = path.to_string_lossy().to_string(); - write(&path, &bytes)?; - f["path"] = json!(path); - } - None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, - None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), - } - files.push(f); - } - let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); - if zip { - let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; - r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); - } - Ok(r) -} - -/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` -/// sizes a single page to that page area instead of the layers' bounds. -fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { - let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; - figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) -} - -/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. -pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { - let mut frames = doc.top_frames(page); - frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); - if frames.is_empty() { - return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); - } - let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); - Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) -} - -/// Every top-level frame of a page (reading order), one PDF page each. -fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let page = match id(p, "page") { - Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, - Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), - None => s.page()?, - }; - let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; - let (bytes, pages) = frames_pdf(doc, page, &print)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) - } - None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), - } -} - -fn import(s: &mut Session, p: &Value) -> Result { - let path = string(p, "path"); - let variables = variables_param("file.import", p)?; - let (bytes, name) = if let Some(path) = &path { - (read(path)?, path.clone()) - } else if let Some(d) = string(p, "data") { - let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); - ( - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, - string(p, "name").unwrap_or_else(|| "import.png".into()), - ) - } else { - return Err(bad("file.import", "pass `path` or `data`")); - }; - let page = s.page()?; - let (x, y) = (num(p, "x"), num(p, "y")); - let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); - match ext(&name).as_str() { - "svg" => { - let doc = s.doc_mut()?; - let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); - let imported = - figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; - let id = imported.frame; - // Centred on the point (a paste lands in the middle of the view). - if boolean(p, "center").unwrap_or(false) - && let Some(n) = doc.node_mut(id) - { - n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); - } - s.set_selection(vec![id]); - let mut out = json!({"id": id}); - if !imported.notes.is_empty() { - out["warnings"] = json!(imported.notes); - } - Ok(out) - } - "figcraft" | "json" | "fig" | "sketch" => { - let resolver = path.as_deref().map(rest_images); - let images: figcraft_format::rest::ImageResolver = match &resolver { - Some(r) => r, - None => &figcraft_format::rest::no_images, - }; - let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; - let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; - let summary = import_summary(&other.doc); - let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages, "summary": summary}); - if rest { - out["warnings"] = json!(other.issues); - } - Ok(out) - } - _ => { - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - super::create::place_image(s, &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)})) - } - } -} - -/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to -/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers -/// their source and interactions and flows their targets wherever those are. Styles, variables and -/// collections whose id the document already has are kept as they are. -fn append_pages(doc: &mut Document, other: &Document) -> Result> { - for (k, v) in &other.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - for st in &other.styles { - if !doc.styles.iter().any(|s| s.id == st.id) { - doc.styles.push(st.clone()); - } - } - let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); - doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); - let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); - doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); - let mut map = std::collections::BTreeMap::new(); - for pg in &other.pages { - for id in other.subtree(*pg).into_iter().skip(1) { - map.insert(id, doc.alloc_id()); - } - } - // Library copies a .fig import keeps (cached definitions) have no library record to follow: - // the layers of its store come along into the store of `doc`. Recorded copies are left to - // `merge_store`, which shares them with copies `doc` already has. - let cached: Vec = other - .library_store() - .and_then(|s| other.node(s)) - .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) - .unwrap_or_default(); - for root in &cached { - for id in other.subtree(*root) { - map.insert(id, doc.alloc_id()); - } - } - let mut new_pages = Vec::new(); - for pg in &other.pages { - let Some(pn) = other.node(*pg) else { continue }; - let np = doc.add_page(&pn.name); - let mut kind = pn.kind.clone(); - if let NodeKind::Page { flows, .. } = &mut kind { - flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); - } - if let Some(n) = doc.node_mut(np) { - n.kind = kind; - } - for c in &pn.children { - let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *c, np, None)?; - } - new_pages.push(np); - } - if !cached.is_empty() { - let store = doc.ensure_library_store(); - for root in &cached { - let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *root, store, None)?; - } - } - for new in map.values() { - let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); - if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { - n.source = Some(src); - } - } - crate::library::merge_store(doc, other, &map)?; - Ok(new_pages) -} - fn close(s: &mut Session, p: &Value) -> Result { let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; @@ -1807,26 +414,7 @@ fn close(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tab_order_tests { - use super::*; - - #[test] - fn moving_and_closing_inactive_tabs_preserves_the_active_document() { - let mut session = Session::new(); - for name in ["Two", "Three", "Four"] { - session.execute("file.new", &json!({"name": name})).unwrap(); - } - session.execute("doc.switch", &json!({"index": 2})).unwrap(); - let active_uid = session.docs[session.active].uid; - let moved_uid = session.docs[0].uid; - session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); - assert_eq!(session.docs[2].uid, moved_uid); - assert_eq!(session.docs[session.active].uid, active_uid); - session.execute("file.close", &json!({"index": 0})).unwrap(); - assert_eq!(session.docs[session.active].uid, active_uid); - assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); - } -} +mod tab_order_tests; /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { @@ -1839,331 +427,10 @@ fn cycle_doc(s: &mut Session, dir: i64) -> Result { } #[cfg(test)] -mod rest_import_tests { - use super::*; - - /// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text - /// is overridden, and an image fill. - fn kit() -> Value { - let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); - let label = |id: &str, text: &str| { - json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, - "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) - }; - json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ - {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ - {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, - {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, - {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, - "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} - ]} - ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) - } - - fn instance(doc: &Document) -> &Node { - doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() - } - - /// What a .fig import with a cached library component leaves: the main in the hidden store, a - /// visible instance of it and a layer linked to the main's layer. - fn cached_import() -> Document { - let mut doc = Document::new("Cached"); - let page = doc.first_page().unwrap(); - let store = doc.ensure_library_store(); - let main = doc - .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) - .unwrap(); - let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); - let inst = - doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); - let mut copy = doc.node(body).unwrap().clone(); - copy.id = doc.alloc_id(); - copy.parent = None; - copy.source = Some(body); - let copy_id = copy.id; - doc.insert(copy, inst, None).unwrap(); - assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); - doc - } - - /// Pasting layers that use a cached main into another document leaves the main behind, as it - /// does for other mains that are not published: no link points at a layer of the target. - #[test] - fn pasting_instances_of_cached_components_leaves_no_dangling_links() { - let src = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; - let ids = src.subtree(inst); - let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); - let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); - crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); - let pasted = doc.node(map[&inst]).unwrap(); - let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; - assert!(!doc.contains(component), "the main stays behind: {component:?}"); - assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); - } - - #[test] - fn appending_pages_with_cached_library_components_brings_the_components() { - let other = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - // Ids of the target that would collide with the other document's store. - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - append_pages(&mut doc, &other).unwrap(); - assert!(doc.validate().is_empty(), "{:?}", doc.validate()); - assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); - let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(doc.node(component).unwrap().name, "Cached main"); - assert!(doc.is_remote(component), "the main comes along into the store"); - let layer = doc.node(inst.children[0]).unwrap(); - assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); - } - - #[test] - fn rest_json_loads_with_linked_instances() { - let bytes = serde_json::to_vec(&kit()).unwrap(); - let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); - assert_eq!(doc.name, "Kit"); - assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); - let inst = instance(&doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(inst.source, Some(component)); - let main_label = doc.node(component).unwrap().children[0]; - let label = doc.node(inst.children[0]).unwrap(); - assert_eq!(label.source, Some(main_label)); - assert!(label.overrides.contains("characters"), "{:?}", label.overrides); - assert!(crate::components::mirror_issues(&doc).is_empty()); - assert!(thumbnail(&doc).is_some()); - } - - #[test] - fn open_takes_data_and_a_name_as_a_new_unsaved_document() { - let mut saved = Session::new(); - saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let bytes = file_bytes(saved.ds().unwrap()).unwrap(); - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - let mut s = Session::new(); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); - let open = s.docs.len(); - assert_eq!(r["index"], open - 1, "a document of its own, made active"); - assert_eq!(s.ds().unwrap().path, None, "no path to save over"); - assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); - assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); - assert_eq!(s.docs.len(), open, "a refused open adds nothing"); - } - - #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { - // The browser has no paths: Save as returns the file for a download (#969). - let mut s = Session::new(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); - assert_eq!(r["name"], "Untitled.figcraft"); - assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); - s.execute("file.markSaved", &json!({})).unwrap(); - assert!(!s.ds().unwrap().is_dirty()); - assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - let mut t = Session::new(); - t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); - assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); - } - - #[test] - fn open_and_import_rest_json() { - let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); - std::fs::create_dir_all(dir.join("images")).unwrap(); - let png = { - let mut d = Document::new("x"); - let p = d.first_page().unwrap(); - let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); - export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() - }; - std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); - let path = dir.join("kit.json"); - std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); - let path = path.to_string_lossy().to_string(); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path})).unwrap(); - assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); - assert!(r.get("repairs").is_none()); - assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); - let doc = s.doc().unwrap(); - let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); - let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; - assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); - - // Import into another document: pages are appended and references follow the new ids. - let mut s = Session::new(); - let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); - let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); - assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); - assert!(r["warnings"].is_array(), "{r}"); - let doc = s.doc().unwrap(); - assert_eq!(doc.pages.len(), 2); - let page = doc.node(doc.pages[1]).unwrap(); - assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); - let inst = instance(doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); - let label = doc.node(inst.children[0]).unwrap(); - assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); - assert!(crate::components::mirror_issues(doc).is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod rest_import_tests; #[cfg(test)] -mod staging_write_tests { - use super::*; - - fn dir(name: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). - #[cfg(unix)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::unix::fs::symlink(to, from).is_ok() - } - - #[cfg(windows)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::windows::fs::symlink_file(to, from).is_ok() - } - - #[test] - fn an_existing_staging_file_is_never_written_through() { - let d = dir("existing"); - let path = d.join("shot.svg"); - // The old deterministic staging name, held by an unrelated file: the write must pick its - // own staging name instead of truncating this one. - let old = d.join("shot.svg.tmp-figcraft"); - std::fs::write(&old, b"not yours").unwrap(); - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b""); - assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); - // Just the file and the untouched staging file: no new staging leftovers. - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_staging_symlink_is_never_followed() { - let d = dir("symlink"); - let path = d.join("shot.svg"); - let victim = d.join("victim.txt"); - std::fs::write(&victim, b"innocent").unwrap(); - let old = d.join("shot.svg.tmp-figcraft"); - if !symlink(&old, &victim) { - return; // Making a symlink needs privileges on some Windows setups. - } - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); - assert_eq!(std::fs::read(&path).unwrap(), b""); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn concurrent_writers_of_one_file_never_interleave() { - let d = dir("concurrent"); - let path = d.join("shot.svg"); - for round in 0..5u8 { - let mut handles = Vec::new(); - for w in 0u8..4 { - let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); - handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); - } - let mut wins = 0; - for h in handles { - // On Windows a rename over a file another writer just replaced can lose the race. - if h.join().unwrap().is_ok() { - wins += 1; - } - } - assert!(wins > 0, "round {round}: every writer lost"); - // Whatever landed is one writer's complete payload, never a mix. - let got = std::fs::read(&path).unwrap(); - assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); - assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); - } - // No staging file was left stuck (a failed writer removes its own). - let left = std::fs::read_dir(&d).unwrap().count(); - assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { - let d = dir("failure"); - // The destination is a directory: the rename fails, our staging file is removed and the - // directory is untouched. - let taken = d.join("over-a-directory"); - std::fs::create_dir_all(&taken).unwrap(); - assert!(write(&taken.to_string_lossy(), b"payload").is_err()); - assert!(taken.is_dir()); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); - // A missing parent directory fails without creating anything anywhere. - assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); - assert!(!d.join("no").exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - // A read-only directory refuses the staging file, and the old destination stays. - let ro = d.join("read-only"); - std::fs::create_dir_all(&ro).unwrap(); - let dest = ro.join("keep.svg"); - std::fs::write(&dest, b"keep").unwrap(); - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o500); - std::fs::set_permissions(&ro, perms).unwrap(); - // Running as root would defeat the read-only directory: skip instead of failing. - let probe = ro.join(".probe"); - if std::fs::write(&probe, b"").is_ok() { - let _ = std::fs::remove_file(&probe); - } else { - assert!(write(&dest.to_string_lossy(), b"payload").is_err()); - assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); - assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); - } - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o700); - std::fs::set_permissions(&ro, perms).unwrap(); - } - let _ = std::fs::remove_dir_all(&d); - } -} +mod staging_write_tests; #[cfg(test)] -mod zip_cap_tests { - use super::add_member; - - #[test] - fn zip_members_stop_at_the_limit_and_keep_what_fit() { - let mut members = Vec::new(); - add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); - add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); - let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); - assert!(err.contains("as one zip"), "{err}"); - assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); - } -} +mod zip_cap_tests; diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs new file mode 100644 index 000000000..005afe6ff --- /dev/null +++ b/crates/engine/src/cmd/file/export.rs @@ -0,0 +1,383 @@ +use super::load::{ext, write}; +use super::save::thumbnail; +use super::*; + +/// Encode `ids` (or the page) in `format` at `scale`. +pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { + export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) +} + +/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). +pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); + ids.iter() + .filter_map(|i| { + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } + }) + .reduce(|a, b| a.union(b)) +} + +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return crate::cmd::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); + } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) +} + +/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see +/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. +pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { + export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) +} + +/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour +/// profile; the clipboard wants sRGB). +pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { + let area = export_area(doc, ids, &ExportSetting::default())?; + figcraft_render::render_area(doc, &crate::cmd::edit::z_sorted(doc, ids), area, 2.0) +} + +pub(super) fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "file.export"; + let fig = match string(p, "format") { + Some(f) => f.eq_ignore_ascii_case("fig"), + None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), + }; + if fig { + return export_fig(s, p); + } + let doc = s.doc()?; + let mut ids = targets(s, p); + if ids.is_empty() { + ids = doc.get(s.page()?)?.children.clone(); + } + let mut setting = export_setting(cmd, ExportSetting::default(), p)?; + if string(p, "format").is_none() + && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) + { + setting.format = f; + } + let scale = match size_param(cmd, p)? { + Some(_) => export_scale(doc, &ids, &setting), + None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), + }; + let format = setting.format; + let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; + let mut out = match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) + } + None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), + }; + // Android Vector Drawables say what they changed or left out (an empty list when nothing). + if format == ExportFormat::VectorDrawable { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The whole document as a `.fig` file (experimental: only round trips through our own importer +/// are verified), with the warnings about what was left out. +pub(super) fn export_fig(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let (bytes, warnings) = fig_bytes(doc)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) + } + None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), + } +} + +/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. +pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { + figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) +} + +/// The scale that gives `ids` (their export area together) `setting`'s export size. +pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { + // Size-based exports ask for bounds before encoding. Let encoding report the depth error + // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. + if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { + return 1.0; + } + export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) +} + +/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. +pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { + if let Some(size) = size_param(cmd, p)? { + e.size = size; + } + if let Some(s) = string(p, "suffix") { + e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); + } + if let Some(f) = string(p, "format") { + e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; + } + if let Some(c) = vd_compat_param(cmd, p)? { + e.vd_compat = c; + } + e.print = print_param(cmd, p, e.print)?; + for (key, slot) in [ + ("contentsOnly", &mut e.contents_only), + ("includeBoundingBox", &mut e.include_bounding_box), + ("svgOutlineText", &mut e.svg_outline_text), + ("svgIdAttribute", &mut e.svg_id_attribute), + ("svgSimplifyStroke", &mut e.svg_simplify_stroke), + ] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(e) +} + +/// Whether `p` sets any PDF print option. +fn has_print_param(p: &Value) -> bool { + ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) +} + +/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or +/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. +pub(in crate::cmd) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { + match get(p, "bleed") { + None | Some(Value::Null) => {} + Some(v) => { + let pt = match v { + Value::String(s) => PrintSettings::parse_length(s), + v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), + }; + base.bleed = pt + .filter(|b| *b <= PrintSettings::MAX_BLEED) + .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; + } + } + for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(base) +} + +/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. +fn size_param(cmd: &str, p: &Value) -> Result> { + let size = match get(p, "size") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(s)) => ExportSize::parse(s), + Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), + Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), + }; + size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) +} + +/// Most files one `file.exportLayers` writes. +const MAX_EXPORT_FILES: usize = 1000; + +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +pub(super) fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} + +/// One file of a layer export: the layer, its setting and the file name. +pub struct ExportJob { + pub id: NodeId, + pub setting: ExportSetting, + pub file: String, +} + +/// The files exporting `ids` with their export settings makes, with unique names (repeats get +/// " 2", " 3"…). Layers without settings make none. +pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { + jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) +} + +/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). +pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { + let mut used = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for (id, settings) in items { + let Some(n) = doc.node(id) else { continue }; + let base = export_path(&n.name); + for e in settings { + if out.len() >= MAX_EXPORT_FILES { + return out; + } + let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); + let ext = e.format.ext(); + let file = + (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); + if let Some(file) = file { + out.push(ExportJob { id, setting: e, file }); + } + } + } + out +} + +/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and +/// `..` segments are dropped, so it stays inside the export folder. +fn export_path(name: &str) -> String { + let parts: Vec = + name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); + if parts.is_empty() { "Untitled".into() } else { parts.join("/") } +} + +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). +pub fn safe_file_name(name: &str) -> String { + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() +} + +/// Layers on `page` that have export settings (document order). +pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { + doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() +} + +pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result { + let cmd = "file.exportLayers"; + let doc = s.doc()?; + let assets = boolean(p, "assets").unwrap_or(false); + let mut ids = targets(s, p); + if ids.is_empty() { + ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; + } + let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; + if jobs.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); + } + let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); + // Print options passed here stand in for every PDF setting's own. + let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; + // And a compatibility mode for every Android Vector Drawable. + let compat = vd_compat_param(cmd, p)?; + let mut files = Vec::with_capacity(jobs.len()); + for job in &jobs { + let setting = + ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; + let scale = export_scale(doc, &[job.id], &setting); + let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; + let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); + if setting.format == ExportFormat::VectorDrawable { + f["warnings"] = json!(warnings); + } + match &dir { + Some(d) => { + let path = std::path::Path::new(d).join(&job.file); + // A layer name with slashes exports into folders. + if let Some(parent) = path.parent() { + let _ = crate::file_access::create_dir_all(parent); + } + let path = path.to_string_lossy().to_string(); + write(&path, &bytes)?; + f["path"] = json!(path); + } + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, + None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), + } + files.push(f); + } + let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) +} + +/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` +/// sizes a single page to that page area instead of the layers' bounds. +pub(super) fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { + let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; + figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) +} + +/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. +pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { + let mut frames = doc.top_frames(page); + frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); + if frames.is_empty() { + return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); + } + let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); + Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) +} + +/// Every top-level frame of a page (reading order), one PDF page each. +pub(super) fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let page = match id(p, "page") { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), + None => s.page()?, + }; + let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; + let (bytes, pages) = frames_pdf(doc, page, &print)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) + } + None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } +} diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs new file mode 100644 index 000000000..8c15783ce --- /dev/null +++ b/crates/engine/src/cmd/file/load.rs @@ -0,0 +1,416 @@ +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 { + let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; + Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read(path: &str) -> Result> { + crate::file_access::read(path) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read(path: &str) -> Result> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +/// The bytes of file `path`, refused when it's larger than `max`. +pub(crate) fn read_capped(path: &str, max: u64) -> Result> { + crate::file_access::read_capped(path, max) +} + +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { + crate::file_access::write(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +pub(super) fn ext(path: &str) -> String { + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() +} + +pub(super) fn file_stem(path: &str) -> String { + std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() +} + +/// The counts the import report shows for a freshly loaded document. +pub(super) fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + +/// Load a document from any supported file (REST API file JSON images come from an `images` +/// folder next to the file). +pub fn load(path: &str) -> Result<(Document, Vec)> { + load_path(path, None).map(|(l, _)| (l.doc, l.issues)) +} + +pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { + load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) +} + +/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST +/// import reads its variables from `variables`, else from a variables JSON next to the file. +pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { + let bytes = read(path)?; + let sibling = if variables.is_none() { sibling_variables(path) } else { None }; + load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) +} + +/// Load from bytes. Returns the document with its notes (repairs of a native file, or import +/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` +/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. +pub(super) fn load_with( + bytes: &[u8], + name_hint: &str, + variables: Option<&Value>, + images: figcraft_format::rest::ImageResolver, +) -> Result<(figcraft_format::Loaded, bool)> { + match ext(name_hint).as_str() { + "svg" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) + .map_err(|e| EngineError::Other(e.to_string()))? + .notes; + Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) + } + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; + let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; + doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + Ok((doc.into(), false)) + } + _ => { + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { + let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; + link_instances(&mut doc); + return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + } + let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { + loaded.doc.name = file_stem(name_hint); + } + Ok((loaded, false)) + } + } +} + +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); + platform_tracking(&mut doc); + sketch_line_heights(&mut doc); + link_instances(&mut doc); + reflow_overridden_text(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Instances of a stack sized apart from their main are laid out at their size, and texts in +/// instances whose string an override changed are measured again with the auto layout around them +/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout +/// Sketch saved. +fn reflow_overridden_text(doc: &mut Document) { + let changed: Vec = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } +} + +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. A named style's optical size follows the text size +/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { + style.variations.entry("opsz".into()).or_insert(style.font_size); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } +} + +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if !laid_out && down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Link imported instances to their mains (layer sources, overrides found by comparing values), +/// innermost first so outer instances settle last. +fn link_instances(doc: &mut Document) { + crate::cmd::styles::unfold_imported_bound_alpha(doc); + crate::style_vars::after_load(doc); + crate::cmd::styles::apply_paint_bindings_on_load(doc); + let mut insts: Vec<(usize, NodeId)> = + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); + insts.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (_, i) in insts { + crate::components::rebase_instance(doc, i); + } +} + +/// Largest image file read for a REST API file JSON import. +#[cfg(not(target_arch = "wasm32"))] +const MAX_IMAGE_FILE: u64 = figcraft_doc::ImageData::MAX_BYTES; + +/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to +/// the file. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn rest_images(path: &str) -> impl Fn(&str) -> Option> { + let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); + move |r: &str| { + // Image refs are content hashes; anything else never becomes a path. + if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + return None; + } + let dir = dir.as_ref()?; + ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] + .iter() + .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) + } +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { + figcraft_format::rest::no_images +} + +/// Largest variables JSON read from disk. +#[cfg(not(target_arch = "wasm32"))] +const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; + +/// A local variables JSON next to a REST API file JSON: `.variables.json`, else +/// `variables.json`. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn sibling_variables(path: &str) -> Option { + if ext(path) != "json" { + return None; + } + let file = std::path::Path::new(path); + let dir = file.parent()?; + [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { + let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; + figcraft_format::rest::is_variables_json(&v).then_some(v) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn sibling_variables(_path: &str) -> Option { + None +} + +/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. +pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { + let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); + let v = match get(p, "variables") { + None | Some(Value::Null) => return Ok(None), + Some(v @ Value::Object(_)) => v.clone(), + Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, + Some(Value::String(path)) => parse(&read(path)?)?, + Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), + }; + if !figcraft_format::rest::is_variables_json(&v) { + return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); + } + Ok(Some(v)) +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 000000000..6b724ced5 --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,245 @@ +use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param}; +use super::*; + +/// How many files File ▸ Open recent keeps. +pub const MAX_RECENT: usize = 20; + +/// Put `path` at the top of the recent files. +pub(super) fn remember(s: &mut Session, path: &str) { + let r = &mut s.prefs.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT); +} + +pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { + use crate::file_access::{Access, allow, allowed}; + const CMD: &str = "file.openRecent"; + // Confined to automation roots, the recent files outside them are never listed, opened, removed + // or named: `index` counts the files listed. + let listed = |r: &String| allowed(r, Access::Read); + if p.get("clear").and_then(Value::as_bool) == Some(true) { + s.prefs.recent_files.retain(|r| !listed(r)); + } + if let Some(gone) = string(p, "remove") { + s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); + } + let path = match (string(p, "path"), num(p, "index")) { + (Some(path), _) => { + allow(&path, Access::Read)?; + Some(path) + } + (None, Some(i)) => { + Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) + } + _ => None, + }; + if let Some(path) = path { + if !crate::file_access::exists(&path) { + s.prefs.recent_files.retain(|r| *r != path); + return Err(bad(CMD, format!("{path} no longer exists"))); + } + return open(s, &json!({"path": path})); + } + let files: Vec = s + .prefs + .recent_files + .iter() + .filter(|r| listed(r)) + .map(|r| { + let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); + json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) + }) + .collect(); + Ok(json!({"files": files})) +} + +pub(super) fn open(s: &mut Session, p: &Value) -> Result { + let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; + // Already open: switch to it. + if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { + s.active = i; + remember(s, &path); + return Ok(json!({"index": i})); + } + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); + let (loaded, rest) = load_path(&path, variables.as_ref())?; + remember(s, &path); + let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); + let warnings = rest || ext(&path) == "svg"; + // A native file that lost something (a damaged image, say): the app says so, not only the result. + if native && !loaded.issues.is_empty() { + s.message = Some(match loaded.issues.as_slice() { + [one] => format!("{}: {one}", file_stem(&path)), + more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), + }); + } + let summary = import_summary(&loaded.doc); + let images_left_out = if native { loaded.images_past_budget } else { 0 }; + let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, images_left_out); + ds.on_disk = on_disk.filter(|_| native); + s.docs.push(ds); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +pub(super) fn import(s: &mut Session, p: &Value) -> Result { + let path = string(p, "path"); + let variables = variables_param("file.import", p)?; + let (bytes, name) = if let Some(path) = &path { + (read(path)?, path.clone()) + } else if let Some(d) = string(p, "data") { + let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); + ( + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, + string(p, "name").unwrap_or_else(|| "import.png".into()), + ) + } else { + return Err(bad("file.import", "pass `path` or `data`")); + }; + let page = s.page()?; + let (x, y) = (num(p, "x"), num(p, "y")); + let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + match ext(&name).as_str() { + "svg" => { + let doc = s.doc_mut()?; + let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); + let imported = + figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; + let id = imported.frame; + // Centred on the point (a paste lands in the middle of the view). + if boolean(p, "center").unwrap_or(false) + && let Some(n) = doc.node_mut(id) + { + n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); + } + s.set_selection(vec![id]); + let mut out = json!({"id": id}); + if !imported.notes.is_empty() { + out["warnings"] = json!(imported.notes); + } + Ok(out) + } + "figcraft" | "json" | "fig" | "sketch" => { + let resolver = path.as_deref().map(rest_images); + let images: figcraft_format::rest::ImageResolver = match &resolver { + Some(r) => r, + None => &figcraft_format::rest::no_images, + }; + let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; + let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); + let pages = append_pages(s.doc_mut()?, &other.doc)?; + let mut out = json!({"pages": pages, "summary": summary}); + if rest { + out["warnings"] = json!(other.issues); + } + Ok(out) + } + _ => { + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + crate::cmd::create::place_image( + s, + &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}), + ) + } + } +} + +/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to +/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers +/// their source and interactions and flows their targets wherever those are. Styles, variables and +/// collections whose id the document already has are kept as they are. +pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result> { + for (k, v) in &other.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + for st in &other.styles { + if !doc.styles.iter().any(|s| s.id == st.id) { + doc.styles.push(st.clone()); + } + } + let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); + doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); + let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); + let mut map = std::collections::BTreeMap::new(); + for pg in &other.pages { + for id in other.subtree(*pg).into_iter().skip(1) { + map.insert(id, doc.alloc_id()); + } + } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } + let mut new_pages = Vec::new(); + for pg in &other.pages { + let Some(pn) = other.node(*pg) else { continue }; + let np = doc.add_page(&pn.name); + let mut kind = pn.kind.clone(); + if let NodeKind::Page { flows, .. } = &mut kind { + flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); + } + if let Some(n) = doc.node_mut(np) { + n.kind = kind; + } + for c in &pn.children { + let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *c, np, None)?; + } + new_pages.push(np); + } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } + for new in map.values() { + let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); + if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { + n.source = Some(src); + } + } + crate::library::merge_store(doc, other, &map)?; + Ok(new_pages) +} diff --git a/crates/engine/src/cmd/file/rest_import_tests.rs b/crates/engine/src/cmd/file/rest_import_tests.rs new file mode 100644 index 000000000..189edc605 --- /dev/null +++ b/crates/engine/src/cmd/file/rest_import_tests.rs @@ -0,0 +1,187 @@ +use super::*; + +/// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text +/// is overridden, and an image fill. +fn kit() -> Value { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let label = |id: &str, text: &str| { + json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, + "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) + }; + json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ + {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, + {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, + {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, + "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} + ]} + ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) +} + +fn instance(doc: &Document) -> &Node { + doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() +} + +/// What a .fig import with a cached library component leaves: the main in the hidden store, a +/// visible instance of it and a layer linked to the main's layer. +fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc +} + +/// Pasting layers that use a cached main into another document leaves the main behind, as it +/// does for other mains that are not published: no link points at a layer of the target. +#[test] +fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); +} + +#[test] +fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); +} + +#[test] +fn rest_json_loads_with_linked_instances() { + let bytes = serde_json::to_vec(&kit()).unwrap(); + let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); + assert_eq!(doc.name, "Kit"); + assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); + let inst = instance(&doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(inst.source, Some(component)); + let main_label = doc.node(component).unwrap().children[0]; + let label = doc.node(inst.children[0]).unwrap(); + assert_eq!(label.source, Some(main_label)); + assert!(label.overrides.contains("characters"), "{:?}", label.overrides); + assert!(crate::components::mirror_issues(&doc).is_empty()); + assert!(thumbnail(&doc).is_some()); +} + +#[test] +fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); +} + +#[test] +fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); +} + +#[test] +fn open_and_import_rest_json() { + let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); + std::fs::create_dir_all(dir.join("images")).unwrap(); + let png = { + let mut d = Document::new("x"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); + export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() + }; + std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); + let path = dir.join("kit.json"); + std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); + let path = path.to_string_lossy().to_string(); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path})).unwrap(); + assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); + assert!(r.get("repairs").is_none()); + assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); + let doc = s.doc().unwrap(); + let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); + let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); + + // Import into another document: pages are appended and references follow the new ids. + let mut s = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); + let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); + assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); + assert!(r["warnings"].is_array(), "{r}"); + let doc = s.doc().unwrap(); + assert_eq!(doc.pages.len(), 2); + let page = doc.node(doc.pages[1]).unwrap(); + assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); + let inst = instance(doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); + let label = doc.node(inst.children[0]).unwrap(); + assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); + assert!(crate::components::mirror_issues(doc).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 000000000..593c4c215 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,225 @@ +use super::export::export_fig; +use super::load::{ext, file_stem, write}; +use super::open::remember; +use super::*; + +/// Thumbnail PNG of the first page's content (for file browsers / the home screen). +pub fn thumbnail(doc: &Document) -> Option> { + let page = doc.first_page()?; + let b = doc.page_content_bounds(page)?; + let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; + figcraft_render::encode_png(&pix).ok() +} + +/// The `.figcraft` file for a document: content, thumbnail and version history. +pub fn file_bytes(ds: &DocState) -> Result> { + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; + refuse_dropping_images(ds, path)?; + refuse_overwriting_changes(ds, path)?; + write(path, bytes)?; + ds.note_on_disk(); + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { + match ds.images_lost_saving_to(path) { + Some(count) => Err(EngineError::Other(format!( + "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", + file_stem(path) + ))), + None => Ok(()), + } +} + +/// Saving over the tab's own file would lose what another app wrote to it since (#1330). +fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { + match (&ds.on_disk, crate::library::stat(path)) { + (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( + "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", + file_stem(path) + ))), + _ => Ok(()), + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } +} + +pub(super) fn color_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "file.colorProfile"; + let Some(name) = string(p, "profile") else { + s.request_ui("dialog.colorProfile", json!({})); + return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); + }; + let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; + let convert = boolean(p, "convert").unwrap_or(true); + let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); + Ok(json!({"profile": profile.id(), "changed": changed})) +} + +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { + return export_fig(s, &json!({"path": path})); + } + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + // A download (no path) writes over no file. + if let Some(path) = path.as_deref() { + refuse_dropping_images(ds, path)?; + } + let mut snapshot = FileSnapshot::of(ds); + let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); + } + let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + ds.note_on_disk(); + mark_saved(s, &json!({}))?; + remember(s, &path); + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +pub(super) fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + +pub(super) fn save(s: &mut Session, p: &Value) -> Result { + if let Some(path) = string(p, "path") { + return save_to(s, Some(path)); + } + let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; + match ds.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } + Some(path) => { + if p.get("overwrite").and_then(Value::as_bool) != Some(true) { + refuse_overwriting_changes(ds, &path)?; + } + save_to(s, Some(path)) + } + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) + } + } +} + +pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { + match string(p, "path") { + Some(mut path) => { + if ext(&path).is_empty() { + path.push_str(".figcraft"); + } + save_to(s, Some(path)) + } + None if wants_data(p) => save_to(s, None), + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) + } + } +} diff --git a/crates/engine/src/cmd/file/staging_write_tests.rs b/crates/engine/src/cmd/file/staging_write_tests.rs new file mode 100644 index 000000000..5653ce78d --- /dev/null +++ b/crates/engine/src/cmd/file/staging_write_tests.rs @@ -0,0 +1,119 @@ +use super::*; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/cmd/file/tab_order_tests.rs b/crates/engine/src/cmd/file/tab_order_tests.rs new file mode 100644 index 000000000..f884ee421 --- /dev/null +++ b/crates/engine/src/cmd/file/tab_order_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); +} diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs new file mode 100644 index 000000000..a46fab8ed --- /dev/null +++ b/crates/engine/src/cmd/file/version.rs @@ -0,0 +1,172 @@ +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}); + if v.auto { + j["auto"] = json!(true); + } + j +} + +/// Autosaves kept per file; older ones make room for new ones. +pub const MAX_AUTOSAVES: usize = 20; + +/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. +pub(super) fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; + let ds = s.ds_mut()?; + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { + return Ok(json!({"saved": false})); + } + Ok(match add_version(ds, AUTOSAVE, "", true) { + Some(v) => json!({"saved": true, "version": v}), + None => json!({"saved": false}), + }) +} + +/// What checkpoints are called. +const AUTOSAVE: &str = "Autosave"; + +/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest +/// autosave makes room when the autosaves or the history are full; versions the user named are +/// never dropped, so a history full of them takes nothing (None). +pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { + let autos = ds.versions.iter().filter(|v| v.auto).count(); + if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) + && let Some(oldest) = ds.versions.iter().position(|v| v.auto) + { + ds.versions_mut().remove(oldest); + } + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return None; + } + let mut v = snapshot_version(ds, name, description); + v.auto = auto; + let out = version_json(ds.versions.len(), &v); + ds.versions_mut().push(v); + ds.mark_checkpoint(); + Some(out) +} + +/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless +/// it is what the last version saved or restored holds. +pub(crate) fn keep_in_history(ds: &mut DocState) { + if ds.branch_of.is_none() && ds.changed_since_checkpoint() { + add_version(ds, AUTOSAVE, "", true); + } +} + +/// A version of `ds`'s document as it is now, opening on the page shown. +fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { + let mut snap = ds.doc.clone(); + if snap.current_page != Some(ds.page) { + Arc::make_mut(&mut snap).current_page = Some(ds.page); + } + Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, crate::cmd::comment::now_secs(), snap) +} + +pub(super) fn version_list(ds: &DocState) -> Value { + let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); + Value::Array( + ds.versions + .iter() + .enumerate() + .map(|(i, v)| { + let mut j = version_json(i, v); + if previewing == Some(v.id.as_str()) { + j["previewing"] = json!(true); + } + j + }) + .collect(), + ) +} + +/// The version named by `id`, else by `index` (validated: never negative, never past the end). +pub(super) fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { + if let Some(id) = string(p, "id") { + return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); + } + if get(p, "index").is_none() { + return Err(bad(cmd, "pass the version's `id` or `index`")); + } + let i = req_index(cmd, p, "index")?; + if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } +} + +pub(crate) fn read_version(v: &Version) -> Result> { + v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) +} + +/// A new version or branch id: the time in milliseconds (hex), not `taken`. +pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { + let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + loop { + let id = format!("{n:x}"); + if !taken(&id) { + return id; + } + n = n.wrapping_add(1); + } +} + +/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. +pub(super) fn save_version(s: &mut Session, p: &Value) -> Result { + let ds = s.ds_mut()?; + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return Err(EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + )); + } + let name = + string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); + let description = string(p, "description").unwrap_or_default(); + // The version opens on the page shown when it was saved. + add_version(ds, &name, description.trim(), false).ok_or_else(|| { + EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + ) + }) +} + +pub(super) fn restore_version(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "version.restore"; + // Refused before anything goes into the history. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } + let ds = s.ds_mut()?; + let i = version_index(CMD, ds, p)?; + let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; + let (id, mut doc) = (v.id.clone(), read_version(v)?); + // Restoring never loses work: the document as it is goes into the history first. + keep_in_history(ds); + let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); + let d = Arc::make_mut(&mut doc); + d.name = ds.doc.name.clone(); + // What the file published belongs to the file, not to the version. + d.published = ds.doc.published.clone(); + // Comments are a conversation about the file, kept apart from its design history: they stay, + // and those on layers the version doesn't have stay where they were. + d.comments = ds.doc.comments.clone(); + crate::cmd::comment::follow_anchors(d, &ds.doc); + // The snapshot is already consistent: nothing to re-normalise. + d.take_dirty(); + // Stay on the current page when the version has it. + ds.page = Some(ds.page) + .filter(|pg| d.pages.contains(pg)) + .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) + .or_else(|| d.first_page()) + .unwrap_or(ds.page); + ds.doc = doc; + ds.selection.clear(); + ds.text_edit = None; + ds.vector_edit = None; + // The restored design is in the history already: restoring it again keeps nothing new. + ds.mark_checkpoint(); + Ok(out) +} diff --git a/crates/engine/src/cmd/file/zip_cap_tests.rs b/crates/engine/src/cmd/file/zip_cap_tests.rs new file mode 100644 index 000000000..ee3e461ba --- /dev/null +++ b/crates/engine/src/cmd/file/zip_cap_tests.rs @@ -0,0 +1,11 @@ +use super::add_member; + +#[test] +fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); +} diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index bb11f0117..c82e30d1d 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 5a0a40003..e22ca4984 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 7892102dd..0c7edbb2b 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 287c9ddd0..e53291b6d 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 08e65b0b8..fefd8c73f 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 @@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result { Ok(target) } +/// The page and ruler guide commands. pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { @@ -106,8 +107,10 @@ pub fn specs() -> Vec { copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy))); } // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. - if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() - && let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind + let guides = doc.get(src)?.guides.clone(); + doc.get_mut(np)?.guides = guides; + if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone() + && let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind { *b = background; *f = flows @@ -115,7 +118,6 @@ pub fn specs() -> Vec { .filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl })) .collect(); *pr = prototype; - *g = guides; } // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a103932..469fcb01a 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 ce7219f44..d0822db9e 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 { @@ -158,7 +161,7 @@ pub fn specs() -> Vec { spec!( "proto.linkNotice", "Notice before leaving for a link", - "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}", + "{show? (default true; false skips it)} whether a presented prototype's Open link the viewer clicked (or fired by a key) first says that it leaves the prototype, with Open link and Cancel (a link a delay or hover fired always does); kept with the preferences → {shown}", false, |s, p| { let show = boolean(p, "show").unwrap_or(true); @@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result { 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 d288a7d71..95a53a9d4 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 08a5a7cc8..28b02c531 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 700825f2d..05823cabf 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 9c2922d21..0066602ae 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 3822c5c5f..e8433fa5e 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 53b62ee46..155fe5195 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 6bd5bb40e..b9e73f13c 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 7364557fc..97b947a59 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 1ee785e69..24e03e4bb 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/codegen.rs b/crates/engine/src/codegen.rs deleted file mode 100644 index 5064c64f1..000000000 --- a/crates/engine/src/codegen.rs +++ /dev/null @@ -1,1755 +0,0 @@ -//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; -//! stacks for auto layout, absolute offsets otherwise). Components become components whose -//! properties are parameters and instances become calls passing their values (see -//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The -//! output is a starting point, not a pixel-exact translation. - -use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; -use serde_json::json; - -use crate::cmd::CommandSpec; -use crate::component_codegen::{Component, Param}; -use crate::params::*; -use crate::{bad, spec}; - -pub fn specs() -> Vec { - vec![spec!( - "inspect.code", - "Code for layer", - "{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", - false, - |s, p| { - let cmd = "inspect.code"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?, - None => CodeLang::Css, - }; - let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { - None | Some("px") => None, - Some("rem") => Some(num_or(p, "rootFontSize", 16.0)).filter(|r| r.is_finite() && *r > 0.0 && *r <= 1000.0), - Some(_) => return Err(bad(cmd, "unit: px|rem")), - }; - if string(p, "unit").is_some_and(|u| u.eq_ignore_ascii_case("rem")) && rem.is_none() { - return Err(bad(cmd, "rootFontSize: a positive number of pixels (up to 1000)")); - } - let doc = s.doc()?; - if !doc.contains(id) { - return Err(bad(cmd, "no such layer")); - } - let connected = crate::code_connect::link(doc, id, lang).is_some(); - let mobile = match get(p, "mobile") { - None | Some(serde_json::Value::Null) => None, - Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), - }; - let code = match (lang, mobile) { - (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), - _ => code_for(doc, id, lang), - }; - let code = match rem { - Some(root) if lang.is_web() => px_to_rem(&code, root), - _ => code, - }; - Ok(json!({"code": code, "language": lang.id(), "connected": connected})) - } - )] -} - -/// Languages the Inspect panel offers. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum CodeLang { - #[default] - Css, - /// The whole layer tree as nested HTML elements with a stylesheet. - Html, - /// A React function component (TSX) with its stylesheet. - React, - /// A Leptos component (`view!` markup) with its stylesheet. - Leptos, - /// A Vue single-file component (`