mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 03:01:45 +00:00
Merge main into #1400
This commit is contained in:
@@ -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"
|
||||
@@ -23,3 +23,27 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -26,8 +26,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-dir: FigCraft
|
||||
install-relative-path: Programs/ArtCraft/FigCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -62,6 +62,7 @@ code.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
|
||||
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
|
||||
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
|
||||
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
|
||||
safety nets, not substitutes for `Result`.
|
||||
|
||||
Generated
+233
-42
@@ -942,12 +942,60 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -972,7 +1020,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
@@ -1545,7 +1593,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-update",
|
||||
@@ -1573,7 +1621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1587,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1603,7 +1651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1626,7 +1674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1635,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1647,7 +1695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1679,7 +1727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1695,7 +1743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1703,7 +1751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1711,7 +1759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1727,7 +1775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1736,7 +1784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1753,7 +1801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1766,7 +1814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1784,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1808,7 +1856,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1824,7 +1872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"figcraft-doc",
|
||||
@@ -1840,10 +1888,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -1867,7 +1916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1881,7 +1930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -1913,6 +1962,17 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2399,9 +2459,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -2412,43 +2472,110 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.4.0"
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.4.0"
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
@@ -2461,18 +2588,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.4.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2480,6 +2610,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2731,7 +2885,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2844,6 +2998,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3684,6 +3841,8 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4681,6 +4840,18 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5165,6 +5337,12 @@ dependencies = [
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -6152,11 +6330,20 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -6235,7 +6422,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
@@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6434,6 +6624,7 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+11
-6
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -35,13 +35,15 @@ figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -100,6 +102,9 @@ webbrowser = "1"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
# Style lints from newer toolchains that fight index-heavy pixel code.
|
||||
needless_range_loop = "allow"
|
||||
@@ -128,7 +133,7 @@ lto = "thin"
|
||||
codegen-units = 4
|
||||
|
||||
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
|
||||
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
|
||||
[profile.web-release]
|
||||
inherits = "release"
|
||||
@@ -160,4 +165,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
|
||||
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
|
||||
}
|
||||
|
||||
/// The links file keeps hashes only, never the codes or the token they wrap.
|
||||
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
|
||||
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
|
||||
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let dir = TempDir::new("codes-share");
|
||||
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
|
||||
// Someone types the edit code, the way people type.
|
||||
let mut b = app("Bo", &url);
|
||||
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
|
||||
|
||||
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
|
||||
}
|
||||
}
|
||||
|
||||
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
|
||||
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_enable_update_and_usage() {
|
||||
let dir = TempDir::new("library");
|
||||
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
|
||||
|
||||
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
junk_is_refused(addr, &edit);
|
||||
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
|
||||
|
||||
// Kept on disk: the library is there after the server stops.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Admission, String> {
|
||||
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<Server, String> {
|
||||
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<String> {
|
||||
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<HostInfo> {
|
||||
Some(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
|
||||
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<OpenFile>) -> 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<OpenFile>) -> 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
|
||||
}
|
||||
}
|
||||
@@ -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::<IpAddr>().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<Item = &(&str, Vec<_>, 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<IpAddr>,
|
||||
fits: &dyn Fn(u64) -> Result<(), String>,
|
||||
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), 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<OpenFile>) -> Vec<FileRow> {
|
||||
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<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
let due: Vec<String> =
|
||||
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<IpAddr, String> {
|
||||
let ip = ip_key(ip.trim().parse::<IpAddr>().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<String> = 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<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Server, String> {
|
||||
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<Log, RewriteError> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u64, ()> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u64>) -> Result<LibraryReply, String> {
|
||||
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<u64, String> {
|
||||
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<Option<&mut UsageCache>, 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<Vec<FileUsage>, 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) }
|
||||
}
|
||||
@@ -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<OpenFile>) -> Vec<FileInfo> {
|
||||
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<Vec<LinkInfo>, 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<NewLink, String> {
|
||||
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<usize> {
|
||||
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<Item = Sealed>) {
|
||||
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<String, String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+15
-1038
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
/// An address was blocked ([`ip_key`]): its connections are dropped.
|
||||
pub blocked: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl From<AdminReply> 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<Admission, String>;
|
||||
|
||||
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
|
||||
fn open(&mut self, file: &str) -> Result<Server, String>;
|
||||
|
||||
/// 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<String> {
|
||||
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<OpenFile>) -> 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<HostInfo> {
|
||||
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<HostInfo, String> {
|
||||
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<OpenFile>) -> 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;
|
||||
}
|
||||
@@ -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<IpAddr> {
|
||||
let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?;
|
||||
let entry = last.rsplit(',').next()?.trim();
|
||||
entry.parse::<IpAddr>().ok().or_else(|| entry.parse::<SocketAddr>().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<AtomicUsize>,
|
||||
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<AtomicUsize>,
|
||||
pub(super) readers: Readers,
|
||||
}
|
||||
|
||||
pub(super) fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, 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<Bytes> {
|
||||
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<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, 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::<ClientMsg>(&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<HubIn>, 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<Response, ErrorResponse> {
|
||||
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::<Out>(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<Duplex>,
|
||||
conn: u64,
|
||||
hub: &Sender<HubIn>,
|
||||
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<Duplex>, rx: &Receiver<Out>, 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<Duplex>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JoinHandle<()>> {
|
||||
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<HubIn>,
|
||||
stop: Arc<AtomicBool>,
|
||||
open: Arc<AtomicUsize>,
|
||||
/// Bytes queued for every connection together, and in flight to the hub.
|
||||
queued: Arc<AtomicUsize>,
|
||||
in_flight: InFlight,
|
||||
blocks: Blocks,
|
||||
reading: Arc<AtomicUsize>,
|
||||
hub_thread: Option<JoinHandle<()>>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Hosting {
|
||||
/// Serve `backend`'s files on `listener`.
|
||||
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
|
||||
Self::start(listener, backend, limits, None)
|
||||
}
|
||||
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
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<AtomicUsize>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Mutex<HashMap<Block, usize>>>);
|
||||
|
||||
/// 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<Counted> {
|
||||
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<Vec<Counted>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Charge>),
|
||||
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<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(n: usize, counter: &Arc<AtomicUsize>) -> Arc<Held> {
|
||||
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<Arc<Held>>,
|
||||
pub(super) blocks: Vec<(usize, Arc<Held>)>,
|
||||
/// 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<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
|
||||
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<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
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<AtomicUsize>,
|
||||
pub(super) reading: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The blocks with open connections; a block leaves when its last connection ends.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Blocks(pub(crate) Arc<Mutex<HashMap<IpAddr, BlockUse>>>);
|
||||
|
||||
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<AtomicUsize>);
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<String> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<String, Value> {
|
||||
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;
|
||||
@@ -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<Item = (&'a String, &'a String)> + '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<f64>; 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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<NodeId> {
|
||||
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
|
||||
}
|
||||
@@ -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<NodeId>) -> Vec<NodeId> {
|
||||
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
|
||||
}
|
||||
@@ -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<NodeId, DocError> {
|
||||
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<usize>) -> Result<NodeId, DocError> {
|
||||
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<usize>) -> Result<NodeId, DocError> {
|
||||
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<NodeId, String> {
|
||||
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/<name>`): 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<Node>, 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<Node>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = 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<Node>)>) {
|
||||
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<NodeId> = 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<NodeId> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId>) {
|
||||
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<NodeId> = 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<NodeId> = 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<NodeId> = Vec::new();
|
||||
let mut covered: BTreeSet<NodeId> = 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<NodeId>) {
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
let Some(inst) = owning_instance(doc, id) else { continue };
|
||||
let top_root = inst == id && !inside_instance(doc, id);
|
||||
if top_root
|
||||
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
|
||||
&& (c1 != c2 || p1 != p2)
|
||||
{
|
||||
// Switching the main or a property value of an instance isn't an override.
|
||||
resync.insert(id);
|
||||
}
|
||||
let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root);
|
||||
let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
|
||||
let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
|
||||
if (!groups.is_empty() || !cleared.is_empty())
|
||||
&& let Some(m) = doc.node_mut(id)
|
||||
{
|
||||
for g in groups {
|
||||
m.overrides.insert(g.to_string());
|
||||
}
|
||||
for g in cleared {
|
||||
m.overrides.remove(g);
|
||||
}
|
||||
}
|
||||
if (lost || nested_switch)
|
||||
&& let Some(r) = root_instance(doc, id)
|
||||
{
|
||||
resync.insert(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones
|
||||
/// that are now overrides and the overrides that were set back to the main's value.
|
||||
fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups {
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, resizing, export settings, interactions and variable modes are
|
||||
// its own, not overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
}
|
||||
// A value equal to the main's isn't an override (setting it back clears the override).
|
||||
let mut cleared = Vec::new();
|
||||
if let Some(src) = n.source.and_then(|s| doc.node(s)) {
|
||||
let mut differs = diff_groups(src, n);
|
||||
if inst == id {
|
||||
differs.retain(|g| *g != "name");
|
||||
if n.name != default_instance_name(doc, src.id) {
|
||||
differs.push("name");
|
||||
}
|
||||
}
|
||||
// A value from the same variable as the main's (resolved in this layer's modes) isn't an
|
||||
// override either.
|
||||
let from_variables = variable_groups(n, src);
|
||||
groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
|
||||
// Binding another variable (or none) where the main binds one is an override.
|
||||
for (k, _) in b.bound.iter().chain(n.bound.iter()) {
|
||||
let Some(g) = binding_group(k) else { continue };
|
||||
let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
|
||||
if rebound && !groups.contains(&g) {
|
||||
groups.push(g);
|
||||
}
|
||||
}
|
||||
cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
|
||||
if top_root {
|
||||
cleared.retain(|g| !matches!(*g, "component" | "props"));
|
||||
}
|
||||
}
|
||||
(raw, groups, cleared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
|
||||
pub(crate) static FULL_REBUILD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: edits carried to the copies without rebuilding instances.
|
||||
pub(crate) static PATCHED: std::cell::Cell<usize> = 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<Vec<&'static str>> {
|
||||
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<NodeId, Vec<NodeId>> {
|
||||
let mut copies: BTreeMap<NodeId, Vec<NodeId>> = 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<Vec<NodeId>> {
|
||||
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<NodeId> = 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<NodeId, NodeId> = 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<NodeId> {
|
||||
let sub = doc.subtree(src);
|
||||
let map: BTreeMap<NodeId, NodeId> = 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<NodeId>) {
|
||||
if comps.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Outermost instances by the components their layers instantiate.
|
||||
let mut roots_of: BTreeMap<NodeId, BTreeSet<NodeId>> = 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<NodeId, usize> = comps.iter().map(|c| (*c, 0)).collect();
|
||||
let mut outer: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
|
||||
let mut frontier: Vec<NodeId> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId, Option<Vec<NodeId>>>,
|
||||
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
|
||||
main_changes: BTreeMap<NodeId, bool>,
|
||||
}
|
||||
|
||||
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<Vec<NodeId>> {
|
||||
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<bool> {
|
||||
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<NodeId> {
|
||||
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<Vec<(NodeId, NodeId)>> {
|
||||
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<NodeId> {
|
||||
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<String, NodeId> =
|
||||
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<String> {
|
||||
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<String> = 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<String> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId, std::sync::Arc<Node>>) {
|
||||
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<BTreeMap<String, NodeId>> = None;
|
||||
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
|
||||
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
|
||||
// parent keeps its id and overrides).
|
||||
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
map.insert(component, inst);
|
||||
let mut used: BTreeSet<NodeId> = 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<NodeId> = BTreeSet::new();
|
||||
let mut skipped: BTreeSet<NodeId> = 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<NodeId, Vec<NodeId>> = BTreeMap::new();
|
||||
enclosing.insert(inst, vec![inst]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = 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<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
remeasure: &mut Vec<NodeId>,
|
||||
) {
|
||||
for s in src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
remeasure.push(nid);
|
||||
let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
|
||||
if o.overrides.contains("size") {
|
||||
// Through set_size so vector networks scale with the box.
|
||||
figcraft_layout::set_size(doc, nid, o.width, o.height);
|
||||
if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
|
||||
figcraft_layout::apply_constraints(doc, nid, ow, oh);
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(nid) {
|
||||
carry_overrides(o, n, Carry::Geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nested instances showing another main than the main's copy (swapped): rebuild from it,
|
||||
/// with the old layers below them this rebuild didn't reuse.
|
||||
fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId, std::sync::Arc<Node>>, used: &BTreeSet<NodeId>, depth: usize) {
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
|
||||
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
|
||||
for n in nested {
|
||||
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
.iter()
|
||||
.filter(|(_, x)| {
|
||||
let mut cur = x.parent;
|
||||
for _ in 0..=MAX_DEPTH * 4 {
|
||||
match cur {
|
||||
Some(p) if p == n => return true,
|
||||
Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.map(|(i, x)| (*i, x.clone()))
|
||||
.collect();
|
||||
take_placing_overrides(doc, n, old, &mut below);
|
||||
sync_instance_depth(doc, n, depth + 1, below);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap<NodeId, BTreeMap<String, Value>>) {
|
||||
let mut chain: Vec<&Node> = vec![orig];
|
||||
while chain.len() < enc.len() {
|
||||
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
|
||||
chain.push(next);
|
||||
}
|
||||
for (j, level) in chain.iter().enumerate().rev() {
|
||||
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
|
||||
for (field, prop) in &level.prop_refs {
|
||||
let g = field_group(field);
|
||||
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = props.get(prop) {
|
||||
apply_prop(doc, n, field, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text whose characters or style differ from the main re-measures.
|
||||
fn remeasure_text(doc: &mut Document, remeasure: Vec<NodeId>) {
|
||||
for nid in remeasure {
|
||||
if let Some(n) = doc.node(nid)
|
||||
&& let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
figcraft_layout::set_size(doc, nid, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The component's property defaults with the instance's values for the ones it has.
|
||||
fn effective_props(comp: &Node, root_props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
|
||||
let mut eff_props = default_props(comp);
|
||||
for (k, v) in root_props {
|
||||
if eff_props.contains_key(k) {
|
||||
eff_props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
eff_props
|
||||
}
|
||||
|
||||
type OldIndex = (BTreeMap<NodeId, std::sync::Arc<Node>>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap<NodeId, Option<NodeId>>);
|
||||
|
||||
/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None`
|
||||
/// when several layers mirror it).
|
||||
fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) -> OldIndex {
|
||||
let mut old_ids = old_layers(doc, inst);
|
||||
old_ids.extend(spare.keys().copied());
|
||||
old_ids.sort();
|
||||
old_ids.dedup();
|
||||
let mut old = spare;
|
||||
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
|
||||
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
|
||||
for i in &old_ids {
|
||||
let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
|
||||
if let Some(s) = n.source {
|
||||
if let Some(p) = n.parent {
|
||||
by_source.entry((p, s)).or_insert(*i);
|
||||
}
|
||||
by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
|
||||
}
|
||||
old.insert(*i, n);
|
||||
}
|
||||
(old, by_source, by_src)
|
||||
}
|
||||
|
||||
/// The rebuilt root of instance `inst` of `comp`.
|
||||
fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap<NodeId, NodeId>) -> Node {
|
||||
// Root: follows the main except its own placement, name, size and overrides.
|
||||
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
|
||||
let mut fresh = comp.clone();
|
||||
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<NodeId, std::sync::Arc<Node>>,
|
||||
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
) {
|
||||
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<NodeId> {
|
||||
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<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
|
||||
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<String, std::sync::Arc<Node>>) {
|
||||
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<Node> {
|
||||
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<NodeId> {
|
||||
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(())
|
||||
}
|
||||
+17
-12
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<NodeId> {
|
||||
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<Vec<NodeId>> {
|
||||
let mut by: BTreeMap<BTreeMap<String, String>, Vec<NodeId>> = 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<String, String> {
|
||||
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<String, String>, 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::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Variant properties of a set: property → values, in first-seen order.
|
||||
pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec<String>)> {
|
||||
let mut out: Vec<(String, Vec<String>)> = 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<String, String>) -> Option<NodeId> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
|
||||
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<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = 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
|
||||
}
|
||||
@@ -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<Item = &Vari
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_tests.rs"]
|
||||
mod tests;
|
||||
@@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String {
|
||||
r["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest.
|
||||
fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) {
|
||||
let m = &lib.manifest;
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: a republish bumps only what changed.
|
||||
fn republish_bumps_touched(s: &mut Session, k: &Kit) {
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_manifest_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
@@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() {
|
||||
assert!(lib.doc.node(k.label).is_some());
|
||||
assert!(lib.doc.validate().is_empty());
|
||||
assert_eq!(lib.doc.styles.len(), 1);
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
saved_and_read_back(doc, &lib);
|
||||
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(&mut s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
republish_bumps_touched(&mut s, &k);
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back.
|
||||
fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(s, remote_button), "0000FF");
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance.
|
||||
fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId {
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(s, card_inst).children[0];
|
||||
assert_eq!(fill(s, nested), "00FF00");
|
||||
assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
ex(s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(s, b).children.len(), 2);
|
||||
assert_eq!(node(s, nested).children.len(), 2);
|
||||
assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3));
|
||||
healthy(s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(s, node(s, b).children[0]), "Go");
|
||||
card_inst
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(&s, remote_button), "0000FF");
|
||||
assert_remote_read_only(&mut s, b, remote_button);
|
||||
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(&mut s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(&s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(&s, card_inst).children[0];
|
||||
assert_eq!(fill(&s, nested), "00FF00");
|
||||
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(&s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(&s, b).children.len(), 2);
|
||||
assert_eq!(node(&s, nested).children.len(), 2);
|
||||
assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
|
||||
healthy(&s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
|
||||
let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]);
|
||||
|
||||
// Saved and reopened, the document keeps its links, copies and records.
|
||||
let user_path = path_str(&d.join("app.figcraft"));
|
||||
@@ -212,5 +212,4 @@ impl Session {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::component::{main_of, swap_instance};
|
||||
@@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`).
|
||||
fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap<String, String> {
|
||||
let mut keys = BTreeMap::new();
|
||||
for r in recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// (local component root, the library's component id) by name; the rest go to `out.unmatched`.
|
||||
fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> {
|
||||
let mut pairs = Vec::new();
|
||||
for r in records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
|
||||
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result<Swapped> {
|
||||
let m = &to.manifest;
|
||||
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
})
|
||||
.collect();
|
||||
let records: Vec<ImportedComponent> = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
|
||||
let mut pairs = Vec::new();
|
||||
for r in &records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
let pairs = match_components(doc, &records, m, &mut out);
|
||||
let keys: Vec<NodeId> = pairs.iter().map(|(_, k)| *k).collect();
|
||||
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
|
||||
// A's mains (each variant) and roots → B's.
|
||||
@@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// Styles the document's own layers link to: by name and type.
|
||||
let linked: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
|
||||
let style_recs: Vec<ImportedRecord> = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
|
||||
let mut style_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &style_recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
style_keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
let style_keys = match_styles(doc, &style_recs, m, &mut out);
|
||||
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
|
||||
let styles: BTreeMap<String, String> = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
|
||||
out.styles = styles.len();
|
||||
@@ -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;
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::CodeLang;
|
||||
use crate::params::*;
|
||||
use crate::params::{get, id_of, num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The component a playground for `target` shows: an instance's component, a component or a
|
||||
@@ -8,7 +8,10 @@ use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::PaintKind;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId,
|
||||
VariableType, VariableValue, Variables,
|
||||
};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, stop_binding};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod batch;
|
||||
pub mod doc_urls;
|
||||
pub mod file_access;
|
||||
pub mod inspect;
|
||||
pub mod params;
|
||||
@@ -306,5 +306,4 @@ pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_access_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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<String, Value>) {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
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<Value> =
|
||||
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::<Vec<_>>(),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod board;
|
||||
pub mod board_table;
|
||||
pub mod board_templates;
|
||||
@@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
require(s, &format!("tool.{}", t.id()))?;
|
||||
crate::cmd::view::tool(s, t)
|
||||
}
|
||||
|
||||
fn shape_of(cmd: &str, v: &str) -> Result<BoardShape> {
|
||||
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> {
|
||||
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> {
|
||||
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<Option<StrokeCap>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => crate::cmd::props::enum_of::<StrokeCap>(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<Option<Color>> {
|
||||
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<NodeId> {
|
||||
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)
|
||||
}
|
||||
@@ -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<End> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<End> {
|
||||
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))
|
||||
}
|
||||
@@ -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<NodeId>) {
|
||||
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<NodeId>) {
|
||||
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<NodeId> = 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<NodeId>, 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<Point> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
let mut pts: Vec<Point> = 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<Rect> {
|
||||
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::<Vec<_>>(), 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<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| 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::<Vec<_>>()),
|
||||
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<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = 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<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
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<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| 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<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
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<Side>| 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<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + 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<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
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],
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<Option<NodeId>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<BoardShape>,
|
||||
routing: Option<Routing>,
|
||||
size: Option<f64>,
|
||||
start: Option<StrokeCap>,
|
||||
end: Option<StrokeCap>,
|
||||
}
|
||||
|
||||
impl Restyle {
|
||||
fn of(cmd: &str, p: &Value) -> Result<Restyle> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<Value> {
|
||||
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<NodeId, NodeId> {
|
||||
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<NodeId, NodeId>) -> 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<NodeId, NodeId>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<Value> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<bool> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where something dropped at a page point lands: the frame under the point (or the page) and, in
|
||||
//! an auto layout frame, the child slot by pointer position plus the insertion line to draw.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::edit::{roots, z_sorted};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, string, targets};
|
||||
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -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<Preedit> {
|
||||
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::<Vec<_>>(), ["opsz"]);
|
||||
// Undo restores the previous values.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["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;
|
||||
@@ -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<Preedit> {
|
||||
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::<Vec<_>>(), ["opsz"]);
|
||||
// Undo restores the previous values.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["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);
|
||||
}
|
||||
@@ -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> {
|
||||
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<f32>,
|
||||
}
|
||||
|
||||
/// 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<Guide>,
|
||||
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<NodeId>,
|
||||
/// 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<NodeId>,
|
||||
/// 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<crate::vectoredit::VHit>,
|
||||
/// The edited layer `vector_hover` is on (several layers can be in edit mode).
|
||||
pub vector_hover_on: Option<NodeId>,
|
||||
/// 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<VectorAdd>,
|
||||
/// Vector edit mode, Variable width tool: the width point, handle or path under the pointer.
|
||||
pub width_hover: Option<crate::vectoredit::WHit>,
|
||||
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<Vec<[f64; 2]>>,
|
||||
/// 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<usize>,
|
||||
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<ScaleHint>,
|
||||
/// The selected ellipse's arc handle under the pointer, and the readout while one is dragged
|
||||
/// (see [`crate::arc_handles`]).
|
||||
pub arc_hover: Option<ArcHandle>,
|
||||
pub arc: Option<ArcHint>,
|
||||
/// 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<ShapeHandle>,
|
||||
pub shape: Option<ShapeHint>,
|
||||
}
|
||||
|
||||
/// 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<NodeId>,
|
||||
within: Option<NodeId>,
|
||||
},
|
||||
Move {
|
||||
start: Point,
|
||||
ids: Vec<NodeId>,
|
||||
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<NodeId>,
|
||||
},
|
||||
Resize {
|
||||
start: Point,
|
||||
r: Box<ResizeStart>,
|
||||
scale_tool: bool,
|
||||
moved: bool,
|
||||
},
|
||||
Rotate {
|
||||
center: Point,
|
||||
start_angle: f64,
|
||||
ids: Vec<NodeId>,
|
||||
},
|
||||
Draw {
|
||||
start: Point,
|
||||
id: Option<NodeId>,
|
||||
parent: NodeId,
|
||||
targets: Targets,
|
||||
},
|
||||
Pan {
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
},
|
||||
Pencil {
|
||||
points: Vec<Point>,
|
||||
/// The pen pressure at each point, where the device reports it.
|
||||
pressure: Vec<Option<f32>>,
|
||||
},
|
||||
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<NodeId>,
|
||||
},
|
||||
/// Shape builder: regions collected so far (handled in `shapebuilder`).
|
||||
ShapeBuild {
|
||||
picked: Vec<usize>,
|
||||
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<NodeId>,
|
||||
pub parent: Option<NodeId>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ToolState {
|
||||
pub tool: ToolId,
|
||||
pub(crate) gesture: Gesture,
|
||||
pub overlay: Overlay,
|
||||
pub pen: Option<PenPath>,
|
||||
/// Tool to return to after a temporary tool (space → hand).
|
||||
pub temp_from: Option<ToolId>,
|
||||
/// Last click (for double-click detection by agents that only send downs).
|
||||
pub last_hover: Option<NodeId>,
|
||||
/// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click
|
||||
/// removes; Alt merges).
|
||||
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
|
||||
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<crate::vectoredit::point_snap::PenTargets>,
|
||||
/// 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<PointerEvent>,
|
||||
/// 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<figcraft_doc::Brush>,
|
||||
/// 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<figcraft_color::Color>,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
pub fn busy(&self) -> bool {
|
||||
!matches!(self.gesture, Gesture::None)
|
||||
}
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<usize> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 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<NodeId>| {
|
||||
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
|
||||
};
|
||||
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::<Vec<_>>();
|
||||
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<NodeId> = 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<NodeId> = 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<NodeId> = 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<Guide>) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<Vec<figcraft_color::Paint>> {
|
||||
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<NodeId>) {
|
||||
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<f64> {
|
||||
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<NodeId> = 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<NodeId> = 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<Handle> {
|
||||
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<Handle> {
|
||||
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<Item = (f64, Handle)>| 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::<f64>() / 4.0, corners.iter().map(|c| c.y).sum::<f64>() / 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::<f64>() / 4.0, c.iter().map(|p| p.y).sum::<f64>() / 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<Point> {
|
||||
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<ShapeHint> {
|
||||
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<u8, u8>) {}
|
||||
|
||||
/// 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))
|
||||
})
|
||||
}
|
||||
@@ -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<bool> {
|
||||
// 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<bool> {
|
||||
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<NodeId> {
|
||||
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<Point>) -> 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<NodeId> =
|
||||
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<crate::arc_handles::ArcHandles> {
|
||||
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<serde_json::Value> = 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})
|
||||
}
|
||||
}
|
||||
@@ -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<f32>]) -> Option<Vec<[f64; 2]>> {
|
||||
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<NodeId> = 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<Guide>)> {
|
||||
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<usize>) -> 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<f32>]) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Option<NodeId>> {
|
||||
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<NodeId>, deep: bool, base: &[NodeId], additive: bool) -> Result<Vec<NodeId>> {
|
||||
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<NodeId> = 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<bool> {
|
||||
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<NodeId> = 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ mod width_sides_tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::props::enum_of;
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub(crate) use pointer::VGesture;
|
||||
@@ -2,7 +2,7 @@
|
||||
//! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
+14
-9
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::point_snap;
|
||||
@@ -510,20 +510,25 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Another edited layer under the press (or, with the paint bucket, whose area it is in).
|
||||
fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option<NodeId> {
|
||||
match hit_any(doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false when the click left edit mode (the caller then runs the normal tool).
|
||||
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
// A press on another edited layer's point, handle or segment (or, with the paint bucket,
|
||||
// area) makes it the active layer; a pen drawing a path keeps to its own layer.
|
||||
let other = match hit_any(&doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let other = Self::pressed_layer(&doc, ve, ev, tol);
|
||||
let switched;
|
||||
let ve = match other {
|
||||
Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => {
|
||||
+1
-1
@@ -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};
|
||||
|
||||
+2
-2
@@ -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};
|
||||
|
||||
+1
@@ -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}"))
|
||||
+1
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+19
-1185
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user