diff --git a/.cargo/config.toml b/.cargo/config.toml
index acebfe9f..30d819a2 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
+# The Windows UI Automation adapter can be asked about a node that disappeared in the
+# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
+# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
+# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
+# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
+[target.'cfg(all(windows, target_env = "gnu"))']
+rustflags = ["--cfg=windows_slim_errors"]
+
[alias]
xtask = "run -q -p xtask --"
diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 00000000..2a4cd2cb
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,3 @@
+# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
+# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
+nextest-version = "0.9.150"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1968076f..b79af502 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,11 +21,10 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
- # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
- # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
- # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
- # once one commit has both (storytold/craft-fonts#42).
- craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+ # Combined Latin presentation and core UI catalog. Required web builds enforce
+ # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+ require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 3b03a99c..80adf16a 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
- craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dd2aefdd..f93219fa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,13 +24,12 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
- # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
- # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
- # Re-pin once one commit has both (storytold/craft-fonts#42).
- craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+ # Combined Latin presentation and core UI catalog. Required web builds enforce
+ # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
+ craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
- # crates/text/build.rs keeps the web build to Inter.
+ # crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 96dff6a9..46b789a8 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
-| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
+| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
-| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+
+The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
+FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
+
+## egui dependency font icons
+
+| Asset | Author | Source | Licence | Use |
+|---|---|---|---|---|
+| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
diff --git a/Cargo.lock b/Cargo.lock
index d38bc5d4..24ac91b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,21 +4,21 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
"phf",
"serde",
@@ -27,43 +27,37 @@ dependencies = [
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -71,9 +65,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -89,13 +83,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -103,11 +97,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -258,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -700,9 +695,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -753,9 +748,9 @@ dependencies = [
[[package]]
name = "clipboard-win"
-version = "5.4.1"
+version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -836,6 +831,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -849,8 +854,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-graphics-types 0.2.0",
"foreign-types",
"libc",
]
@@ -862,10 +880,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"libc",
]
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "libc",
+]
+
+[[package]]
+name = "core-text"
+version = "22.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-graphics 0.25.0",
+ "foreign-types",
+]
+
[[package]]
name = "core_detect"
version = "1.0.0"
@@ -902,14 +942,86 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip 9.1.1",
+]
[[package]]
name = "craft-window"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
- "egui",
"wayland-client",
]
@@ -1032,6 +1144,12 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
@@ -1043,6 +1161,12 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1124,10 +1248,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
-name = "ecolor"
-version = "0.36.2"
+name = "dunce"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecolor"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1135,9 +1264,8 @@ dependencies = [
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1159,6 +1287,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
+ "send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1170,9 +1299,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1190,9 +1318,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1210,30 +1337,33 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
"objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1245,9 +1375,8 @@ dependencies = [
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1265,9 +1394,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1292,22 +1420,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
-version = "2.7.3"
+version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
-version = "0.17.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
@@ -1356,9 +1484,8 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1374,16 +1501,17 @@ dependencies = [
"self_cell",
"skrifa 0.44.0",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
@@ -1398,7 +1526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -1468,6 +1596,7 @@ name = "figcraft"
version = "0.11.2"
dependencies = [
"arboard",
+ "craft-update",
"craft-window",
"eframe",
"egui",
@@ -1487,6 +1616,7 @@ dependencies = [
"windows",
"winit",
"winresource",
+ "zbus",
]
[[package]]
@@ -1569,6 +1699,7 @@ version = "0.11.2"
dependencies = [
"base64 0.22.1",
"craft-docpath",
+ "deunicode",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1581,14 +1712,17 @@ dependencies = [
"figcraft-render",
"figcraft-svg",
"figcraft-text",
+ "figcraft-vectordrawable",
"image",
"log",
"regex",
+ "same-file",
"serde",
"serde_json",
"thiserror 2.0.21",
"wat",
"web-time",
+ "zip 2.6.1",
]
[[package]]
@@ -1603,8 +1737,9 @@ dependencies = [
"ruzstd",
"serde",
"serde_json",
+ "sha1",
"sha2",
- "zip",
+ "zip 2.6.1",
]
[[package]]
@@ -1717,7 +1852,7 @@ dependencies = [
"toml",
"tungstenite",
"wat",
- "zip",
+ "zip 2.6.1",
]
[[package]]
@@ -1740,10 +1875,12 @@ dependencies = [
name = "figcraft-text"
version = "0.11.2"
dependencies = [
+ "core-text",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
+ "objc2 0.6.5",
"png",
"skrifa 0.47.0",
"unicode-bidi",
@@ -1756,6 +1893,8 @@ version = "0.11.2"
dependencies = [
"base64 0.22.1",
"chrono",
+ "craft-ui",
+ "craft-update",
"egui",
"egui_extras",
"figcraft-color",
@@ -1776,10 +1915,25 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "figcraft-vectordrawable"
+version = "0.11.2"
+dependencies = [
+ "figcraft-color",
+ "figcraft-doc",
+ "figcraft-geom",
+ "figcraft-pathops",
+ "figcraft-render",
+ "figcraft-text",
+ "roxmltree 0.21.2",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "figcraft-web"
version = "0.11.2"
dependencies = [
+ "base64 0.22.1",
"eframe",
"egui",
"figcraft-collab-net",
@@ -1787,17 +1941,39 @@ dependencies = [
"figcraft-ui-egui",
"js-sys",
"log",
+ "serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "find-msvc-tools"
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"
@@ -2034,9 +2210,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -2062,9 +2240,9 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
@@ -2294,6 +2472,23 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_decimal"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+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"
@@ -2302,11 +2497,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
+[[package]]
+name = "icu_locale_fallback"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+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"
@@ -2318,6 +2534,9 @@ dependencies = [
"icu_properties",
"icu_provider",
"smallvec",
+ "utf16_iter",
+ "utf8_iter",
+ "write16",
"zerovec",
]
@@ -2327,6 +2546,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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.3.0"
@@ -2356,6 +2601,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "postcard",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2363,6 +2611,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"
@@ -2614,7 +2886,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2727,6 +2999,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"
@@ -2757,9 +3032,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
-version = "0.9.11"
+version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
@@ -2898,6 +3173,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3253,6 +3534,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3265,9 +3552,9 @@ dependencies = [
[[package]]
name = "ordered-float"
-version = "5.5.0"
+version = "5.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
dependencies = [
"num-traits",
]
@@ -3289,7 +3576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -3365,9 +3652,9 @@ dependencies = [
[[package]]
name = "phf"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
@@ -3376,9 +3663,9 @@ dependencies = [
[[package]]
name = "phf_generator"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -3386,9 +3673,9 @@ dependencies = [
[[package]]
name = "phf_macros"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
@@ -3399,9 +3686,9 @@ dependencies = [
[[package]]
name = "phf_shared"
-version = "0.13.1"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3461,6 +3748,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "plist"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap",
+ "quick-xml 0.42.0",
+ "serde",
+ "time",
+]
+
[[package]]
name = "png"
version = "0.18.1"
@@ -3542,9 +3842,17 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
[[package]]
name = "presser"
version = "0.3.1"
@@ -3622,6 +3930,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -3882,9 +4199,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
-version = "0.21.1"
+version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
+checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -3933,7 +4250,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -3951,6 +4268,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -3960,6 +4289,33 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
[[package]]
name = "rustls-webpki"
version = "0.103.16"
@@ -4037,6 +4393,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4060,6 +4425,29 @@ dependencies = [
"tiny-skia 0.11.4",
]
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4071,6 +4459,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -4257,9 +4655,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.16.3"
+version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -4443,6 +4841,28 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4450,10 +4870,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -4519,6 +4939,36 @@ dependencies = [
"zune-jpeg",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -4572,11 +5022,12 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -4588,9 +5039,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.54.0"
+version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"pin-project-lite",
]
@@ -4723,6 +5174,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4812,6 +5269,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
+dependencies = [
+ "base64 0.23.1",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "ureq-proto",
+ "utf8-zero",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -4839,7 +5325,7 @@ dependencies = [
"kurbo",
"log",
"pico-args",
- "roxmltree 0.21.1",
+ "roxmltree 0.21.2",
"simplecss",
"siphasher",
"skrifa 0.44.0",
@@ -4852,6 +5338,18 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4866,9 +5364,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.28.0"
+version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"js-sys",
"serde_core",
@@ -4877,9 +5375,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -4892,9 +5390,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -4902,6 +5400,16 @@ dependencies = [
"vello_common",
]
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -5210,7 +5718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml",
+ "quick-xml 0.41.0",
"quote",
]
@@ -5248,9 +5756,9 @@ dependencies = [
[[package]]
name = "webbrowser"
-version = "1.2.4"
+version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
+checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7"
dependencies = [
"jni",
"log",
@@ -5263,10 +5771,19 @@ dependencies = [
]
[[package]]
-name = "webpki-roots"
-version = "1.0.10"
+name = "webpki-root-certs"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
+checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -5476,7 +5993,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -5694,8 +6211,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.30.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -5706,8 +6222,8 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
- "core-foundation",
- "core-graphics",
+ "core-foundation 0.9.4",
+ "core-graphics 0.23.2",
"cursor-icon",
"dpi",
"js-sys",
@@ -5752,6 +6268,16 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "winresource"
version = "0.1.34"
@@ -5762,6 +6288,12 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "winsafe"
+version = "0.0.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -5799,11 +6331,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"
@@ -5890,9 +6431,9 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6071,16 +6612,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "litemap",
+ "serde_core",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
name = "zerovec"
-version = "0.11.8"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -6088,9 +6633,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.6"
+version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
@@ -6112,6 +6657,23 @@ dependencies = [
"zopfli",
]
+[[package]]
+name = "zip"
+version = "9.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -6191,3 +6753,23 @@ dependencies = [
"syn 3.0.9",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_kittest"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index 39abeca3..fd2fbb81 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
+figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
@@ -32,11 +33,17 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+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). The `egui` feature adds `decorate`/`decorations`.
-craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
+# 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 = "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"] }
@@ -45,6 +52,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
+same-file = "1.0.6"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
@@ -63,11 +71,15 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
+# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
+# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
+deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
+sha1 = "0.11"
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
@@ -79,9 +91,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
-egui = "0.36"
-egui_extras = { version = "0.36", default-features = false }
-eframe = { version = "0.36", default-features = false }
+egui = "0.37"
+egui_extras = { version = "0.37", default-features = false }
+eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -130,7 +142,24 @@ debug = false
strip = true
panic = "abort"
+# Keep the complete egui family and native event bridge on reviewed fork commits.
+[patch.crates-io]
+ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+
# 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.
-[patch.crates-io]
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
diff --git a/NOTICE b/NOTICE
index a0baaa27..9970631d 100644
--- a/NOTICE
+++ b/NOTICE
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+
+egui icon font subset (dependency asset)
+----------------------------------------
+epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
+emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
+uses the same MIT license. Upstream license text follows unchanged.
+
+MIT License
+
+Copyright (c) 2014 John Slegers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index ebbf7fa0..e17cd74c 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,16 @@
Live sessions : three apps on one figcraft-server file, with cursors, selections, cursor chat and spotlight
+
+
+
+ Boards : a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions
+
+
+
+ Dev Mode measurements : redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft
+
+
## Made in FigCraft
@@ -198,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
@@ -234,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
-- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
-- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
+- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
+- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
diff --git a/ROADMAP.md b/ROADMAP.md
index 899d8d47..d5b943f7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (M6: offline Penpot import/export using the existing document model, bounded archive and token parsing) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (offline Penpot 2.x import/export; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,19 +13,20 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
+| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
-| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
+| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
-| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
+| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -65,7 +66,7 @@ native speakers' reviews of the nine language drafts need people.
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
-| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
+| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -89,11 +90,11 @@ native speakers' reviews of the nine language drafts need people.
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
-| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
+| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
-| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) |
+| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -110,11 +111,11 @@ native speakers' reviews of the nine language drafts need people.
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
-| Vietnamese | vi | 0% | none |
+| Vietnamese | vi | 100% | draft (needs native review) |
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
-French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
+French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -132,11 +133,24 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
-- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged.
+- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
+- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
+- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
+- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
+- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
+- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
+- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
+ unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
+ into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
+ can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
+ (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
+ 65%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
+- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
@@ -153,6 +167,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
+- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
@@ -336,9 +351,20 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
-| 2026-10-25 | minor | M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. |
+| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
+| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
+| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
+| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
+| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
+| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
+| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
+| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
+| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
+| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
@@ -401,6 +427,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
+| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs
index 0c04b732..740e885b 100644
--- a/apps/figcraft-cli/src/args.rs
+++ b/apps/figcraft-cli/src/args.rs
@@ -91,6 +91,15 @@ impl Args {
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
+ /// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
+ pub fn vd_compat(&self) -> CliResult {
+ match self.value("vd-compat") {
+ None => Ok(figcraft_doc::VdCompat::default()),
+ Some(s) => figcraft_doc::VdCompat::from_key(&s)
+ .ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
+ }
+ }
+
pub fn scale(&self) -> CliResult {
match self.value("scale") {
None => Ok(1.0),
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index e22c16f0..af377b49 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -4,8 +4,8 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
-use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
-use figcraft_engine::Session;
+use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
+use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -31,7 +31,7 @@ fn ext(path: &str) -> String {
fn image_format(e: &str) -> Option {
match e {
- "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
+ "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
@@ -240,6 +240,8 @@ pub struct Target {
pub node: Option,
/// PDF only.
pub print: PrintSettings,
+ /// Android Vector Drawable (XML) only.
+ pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
@@ -259,14 +261,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => image_format(&f.to_ascii_lowercase())
- .ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig|penpot)")))?,
+ .ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
- let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
- figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
+ let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
+ let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
+ // What an Android Vector Drawable changed or left out, like the .fig notes.
+ for w in warnings {
+ eprintln!("note: {w}");
+ }
+ Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
@@ -311,8 +318,34 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult {
Ok(ExitCode::SUCCESS)
}
+fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
+ if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
+ return Err(CliError::Failed(format!(
+ "{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
+ )));
+ }
+ Ok(())
+}
+
+fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
+ if let Some((input, images_left_out)) = input {
+ refuse_dropping_images(input, out, images_left_out)?;
+ }
+ if let Some(images_left_out) = active.images_lost_saving_to(out) {
+ refuse_dropping_images(out, out, images_left_out)?;
+ }
+ Ok(())
+}
+
pub fn convert(input: &str, out: &str) -> CliResult {
- let doc = load(input)?;
+ let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
+ refuse_dropping_images(input, out, loaded.images_past_budget)?;
+ if ext(input) == "penpot" {
+ for note in &loaded.issues {
+ eprintln!("note: {note}");
+ }
+ }
+ let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -337,7 +370,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
-pub fn parse_script(text: &str) -> Result, String> {
+pub fn parse_script(text: &str) -> Result, String> {
let steps: Vec = match serde_json::from_str::(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -362,7 +395,8 @@ pub fn parse_script(text: &str) -> Result, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
- Ok((cmd.to_string(), params))
+ let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
+ Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
@@ -371,43 +405,44 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
- let mut s = match input {
+ let (mut s, input_images_left_out) = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
- s
+ let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
+ (s, left_out)
}
- None => Session::new(),
+ None => (Session::new(), 0),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
- // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
- // a step that fails takes the whole script back instead of leaving the earlier steps applied.
- // The result lines are held back until the script is through, so none of them reports work that
- // was rolled back.
- let mut results: Vec = Vec::with_capacity(steps.len());
+ // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
+ // steps before it, and a step that fails takes the whole script back instead of leaving the
+ // earlier steps applied. The result lines are held back until the script is through, so none of
+ // them reports work that was rolled back.
+ let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
- for (i, (cmd, params)) in steps.iter().enumerate() {
+ for (i, step) in steps.iter().enumerate() {
failed_at = i;
- let v = s.batch_step(&results, i, cmd, params)?;
+ s.batch_step(&mut scope, &step.command, &step.params)?;
s.ui_requests.clear();
- results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
- let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
+ let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
- for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
- println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
+ for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
+ println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
+ refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -530,6 +565,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
+ /// A script names its steps with `as` and reads paths into their results, like `command.batch`.
+ #[test]
+ fn a_script_refers_to_named_steps_and_paths() {
+ let dir = temp_dir("run-ref");
+ let script = dir.join("script.json");
+ std::fs::write(
+ &script,
+ r#"[
+ {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
+ {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
+ {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
+ ]"#,
+ )
+ .unwrap();
+ let out = dir.join("out.figcraft");
+ run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
+
+ let mut s = Session::empty();
+ s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
+ let doc = s.doc().unwrap();
+ let top = doc.node(s.page().unwrap()).unwrap().children.clone();
+ let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
+ assert_eq!(names, ["Card", "Copy"]);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
+ /// Two steps with one name fail before anything runs.
+ #[test]
+ fn a_script_with_a_name_twice_runs_nothing() {
+ let dir = temp_dir("run-dup-name");
+ let script = dir.join("script.json");
+ std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
+ let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
+ assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
@@ -549,4 +621,24 @@ mod tests {
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
+
+ #[test]
+ fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
+ let dir = temp_dir("script-images-left-out");
+ let input = dir.join("big.figcraft");
+ std::fs::write(&input, b"kept").unwrap();
+ let link = dir.join("link.figcraft");
+ std::fs::hard_link(&input, &link).unwrap();
+ let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
+ let duplicate = Session::new();
+ let active = duplicate.ds().unwrap();
+
+ for out in [input, link] {
+ let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
+ assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
+ }
+ assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
+ assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
+ std::fs::remove_dir_all(dir).unwrap();
+ }
}
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
index 21252e5b..905a588e 100644
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info [--json]
- figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
- [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
+ figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
+ [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert
figcraft-cli render -o [--scale N] [--page NAME]
figcraft-cli run [--in ] [--out ]
@@ -34,11 +34,10 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
-wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
-per top-level frame into the directory, or one file when has an image extension or --node
-is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may
-not open every file; `penpot`, or an ending in .penpot: the whole file as a Penpot 2.x
-archive to import into a Penpot team); PDFs take print options (a bleed like 3mm or 0.125in,
+wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
+image layer). `export` writes one file per top-level frame into the directory, or one file when
+ has an image extension or --node is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may
+not open every file; `penpot`, or an ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
@@ -116,6 +115,9 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult CliResult {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
- let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
+ let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export -o ")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?;
- let target =
- files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
+ let target = files::Target {
+ format: a.value("format"),
+ scale: a.scale()?,
+ page: a.value("page"),
+ node: a.value("node"),
+ print: a.print()?,
+ vd_compat: a.vd_compat()?,
+ };
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
diff --git a/apps/figcraft-cli/tests/it/fonts.rs b/apps/figcraft-cli/tests/it/fonts.rs
index a48056bd..998665c4 100644
--- a/apps/figcraft-cli/tests/it/fonts.rs
+++ b/apps/figcraft-cli/tests/it/fonts.rs
@@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
+
+/// A synthetic `.sketch` file: one page with one Helvetica text.
+const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
+
+/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
+/// `--no-system-fonts` too: the run still knows only the built-in families.
+#[test]
+fn sketch_imports_keep_to_no_system_fonts() {
+ let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let script = d.join("open.json");
+ let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
+ std::fs::write(&script, steps.to_string()).unwrap();
+ let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
+ .arg("run")
+ .arg(&script)
+ .arg("--no-system-fonts")
+ .env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
+ assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
+ let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap();
+ assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
+}
diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs
index ae4b0488..2e628773 100644
--- a/apps/figcraft-cli/tests/it/main.rs
+++ b/apps/figcraft-cli/tests/it/main.rs
@@ -10,3 +10,5 @@ mod fonts;
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod vector_drawable;
diff --git a/apps/figcraft-cli/tests/it/vector_drawable.rs b/apps/figcraft-cli/tests/it/vector_drawable.rs
new file mode 100644
index 00000000..b99c32cd
--- /dev/null
+++ b/apps/figcraft-cli/tests/it/vector_drawable.rs
@@ -0,0 +1,41 @@
+//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
+
+use std::process::Command;
+
+fn bin() -> Command {
+ Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
+}
+
+#[test]
+fn xml_export_writes_a_drawable_and_prints_its_notes() {
+ let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
+ std::fs::create_dir_all(&d).unwrap();
+ let script = d.join("s.jsonl");
+ std::fs::write(
+ &script,
+ r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
+ )
+ .unwrap();
+ let file = d.join("doc.figcraft");
+ let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+
+ let xml = d.join("icon.xml");
+ let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let text = std::fs::read_to_string(&xml).unwrap();
+ assert!(text.contains(" Option {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> {
- let body = message::encode(state).map_err(|e| e.to_string())?;
- let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
+ let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
- out.extend_from_slice(&crc(&body).to_le_bytes());
- out.extend_from_slice(&body);
+ out.extend_from_slice(&0u32.to_le_bytes());
+ let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
+ let sum = crc(&out[SNAP_HEADER..]);
+ out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index ab65c2d8..90fce92b 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -52,6 +52,14 @@ use crate::codes::{self, Sealed};
use crate::directory::{Directory, DirectoryConfig};
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
+mod backend;
+mod files;
+mod journal;
+mod library;
+mod links;
+
+pub use library::library_name;
+
pub const LOCK: &str = "LOCK";
pub const KEY: &str = "server.key";
pub const FILES: &str = "files";
@@ -541,844 +549,6 @@ impl Store {
people: open.map_or(0, |o| o.people),
}
}
-
- /// Keep a document (its snapshot) as a new file with an edit link and a view link.
- pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
- self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
- }
-
- /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
- /// caps it gets, and the files and bytes kept by the files published from addresses in it. An
- /// address that doesn't parse counts as itself only, ×1, by its exact text.
- fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
- let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
- let kept: Vec<(&str, Vec<_>, u64)> = self
- .files
- .iter()
- .filter_map(|(id, e)| {
- let from = e.meta.ip.as_deref()?;
- Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
- })
- .collect();
- let sum = |mine: &mut dyn Iterator- , u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
- let blocks = blocks_of(ip);
- if blocks.is_empty() {
- let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
- return vec![(1, files, bytes)];
- }
- blocks
- .into_iter()
- .map(|(block, times)| {
- let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
- (times, files, bytes)
- })
- .collect()
- }
-
- /// Keep a document published without the key from `ip`, within the caps on what one address
- /// and everyone may keep: the file, its links and its owner token.
- pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
- let h = &self.cfg.hosting;
- let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
- if !open {
- return Err(KEY_ONLY.into());
- }
- if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
- return Err("too many files published from your address lately: try again in an hour".into());
- }
- let blocks = self.per_block(&ip.to_string());
- for &(times, files, _) in &blocks {
- if files >= max_files.saturating_mul(times as usize) {
- return Err(format!(
- "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
- ));
- }
- }
- let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
- let fits = |n: u64| {
- for &(times, _, used) in &blocks {
- let cap = max_ip.saturating_mul(u64::from(times));
- if used.saturating_add(n) > cap {
- return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
- }
- }
- if used_all.saturating_add(n) > max_all {
- return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
- }
- Ok(())
- };
- let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
- Ok((info, edit, view, owner.unwrap_or_default()))
- }
-
- /// Keep a document as a new file: published without the key from `ip` (it gets an owner
- /// token), else by the operator. `fits` checks its size against the caps that apply.
- fn keep(
- &mut self,
- name: &str,
- snapshot: &[Change],
- ip: Option
,
- fits: &dyn Fn(u64) -> Result<(), String>,
- ) -> Result<(FileInfo, NewLink, NewLink, Option), String> {
- if self.files.len() >= self.cfg.max_files {
- return Err(format!("the server keeps at most {} files", self.cfg.max_files));
- }
- if let Some(why) = self.low_disk() {
- return Err(why);
- }
- let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
- if replica.document().node_count() > self.cfg.core.max_nodes {
- return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
- }
- let mut doc = replica.document().clone();
- let name = match clean_name(name) {
- n if n.is_empty() => doc.name.clone(),
- n => n,
- };
- doc.name = name.clone();
- let server = Server::new(doc, "", self.cfg.core);
- let state = server.state().map_err(|e| e.to_string())?;
- let bytes = disk::encode_snapshot(1, &state)?;
- if bytes.len() as u64 > self.cfg.max_file_bytes {
- return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
- }
- fits(bytes.len() as u64)?;
- let id = loop {
- let id = token::file_id()?;
- if !self.files.contains_key(&id) && !self.dir(&id).exists() {
- break id;
- }
- };
- let t = now();
- let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
- let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
- let owner = ip.map(|_| token::secret()).transpose()?;
- let meta =
- Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
- let dir = self.dir(&id);
- let written = fs::create_dir_all(&dir)
- .map_err(|e| e.to_string())
- .and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
- .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
- .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
- // Last: a folder without its description is an upload cut short.
- .and_then(|()| write_json(&dir.join(META), &meta));
- if let Err(e) = written {
- let _ = fs::remove_dir_all(&dir);
- return Err(format!("couldn't keep the file: {e}"));
- }
- let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
- self.files.insert(id.clone(), entry);
- let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
- match ip {
- Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
- None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
- }
- Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
- }
-
- /// The files kept, with what the operator moderates by.
- pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec {
- self.files
- .iter()
- .map(|(id, e)| FileRow {
- info: self.info(id, e, open(id)),
- opened: e.meta.opened,
- expires: self.expires(&e.meta),
- open: e.meta.owner.is_some(),
- ip: e.meta.ip.clone().unwrap_or_default(),
- reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
- })
- .collect()
- }
-
- /// Owner token `owner` manages file `file` (one published without the key).
- fn owns(&self, file: &str, owner: &str) -> bool {
- let presented = hash(owner);
- self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
- }
-
- /// What became of the files these `(file id, owner token)` pairs own: each one's name, links
- /// and expiry, or `None` when that token manages no file of that id here (which doesn't say
- /// whether the id exists). Asking opens nothing: it doesn't count as using a file.
- pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
- if files.len() > MAX_OWNED {
- return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
- }
- let one = |(file, owner): &(String, String)| {
- let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
- Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
- };
- AdminReply::Owned(files.iter().map(one).collect())
- }
-
- /// Rename a file (its listing; the document inside keeps its own name).
- pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
- let name = clean_name(name);
- if name.is_empty() {
- return Err("give the file a name".into());
- }
- let path = self.dir(file).join(META);
- let entry = self.entry(file)?;
- let before = std::mem::replace(&mut entry.meta.name, name);
- if let Err(e) = write_json(&path, &entry.meta) {
- entry.meta.name = before;
- return Err(format!("couldn't rename the file: {e}"));
- }
- Ok(())
- }
-
- /// Delete the files published without the key that nobody opened for the idle time (not
- /// those open now); their ids.
- pub fn sweep(&mut self) -> Vec {
- let t = now();
- self.next_sweep = t.saturating_add(SWEEP_EVERY);
- let due: Vec =
- self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
- for id in &due {
- match self.remove(id) {
- Ok(()) => log::info!("event=expired file={id}"),
- Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
- }
- }
- due
- }
-
- /// Make a file look unused for `secs` more (tests: expiry without waiting).
- #[cfg(test)]
- pub fn age(&mut self, file: &str, secs: u64) {
- let path = self.dir(file).join(META);
- if let Some(e) = self.files.get_mut(file) {
- let m = &mut e.meta;
- (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
- let _ = write_json(&path, m);
- }
- }
-
- /// Make an open file's log refuse writes (tests: a full or failing disk).
- #[cfg(test)]
- pub fn break_log(&mut self, file: &str) {
- let path = self.dir(file).join(LOG);
- if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
- && let Ok(f) = File::open(path)
- {
- log.file = f;
- }
- }
-
- /// Note that a file was opened now (it is used: it doesn't expire).
- fn touch(&mut self, file: &str) {
- let path = self.dir(file).join(META);
- if let Some(e) = self.files.get_mut(file) {
- e.meta.opened = now();
- if let Err(err) = write_json(&path, &e.meta) {
- log::warn!("file {file}: couldn't update {META}: {err}");
- }
- }
- }
-
- /// What the server says about its hosting (with `file`'s expiry).
- fn host_info(&self, file: Option<&str>) -> HostInfo {
- let h = &self.cfg.hosting;
- let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
- HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
- }
-
- /// Block or unblock an address (an IPv6 address: its /64); the address as kept.
- pub fn block(&mut self, ip: &str, on: bool) -> Result {
- let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
- if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
- return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
- }
- let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
- if changed {
- let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect();
- write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
- log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
- }
- Ok(ip)
- }
-
- /// The blocked addresses.
- pub fn blocked(&self) -> Vec {
- self.blocked.iter().map(IpAddr::to_string).collect()
- }
-
- /// The reports kept, oldest first.
- pub fn reports(&self) -> &[ReportInfo] {
- &self.reports
- }
-
- /// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
- pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
- let reason = clean_reason(reason);
- if reason.is_empty() {
- return Err("say what is wrong with the file".into());
- }
- let name = self.entry(file)?.meta.name.clone();
- if !self.abuse.allow(ip, Action::Report, Instant::now()) {
- return Err("too many reports from your address lately: try again in an hour".into());
- }
- self.reports.push(ReportInfo {
- at: now(),
- file: file.to_string(),
- name,
- link: link.unwrap_or_default().to_string(),
- ip: ip.to_string(),
- reason,
- });
- if self.reports.len() > MAX_REPORTS {
- let extra = self.reports.len() - MAX_REPORTS;
- self.reports.drain(..extra);
- }
- write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
- log::info!("event=report file={file} ip={ip}");
- Ok(())
- }
-
- /// The files kept.
- pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec {
- self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
- }
-
- /// A file's links.
- pub fn links(&mut self, file: &str) -> Result, String> {
- Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
- }
-
- /// A new link to a file.
- pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result {
- let max = self.cfg.max_links;
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- if entry.links.len() >= max {
- return Err(format!("a file has at most {max} links: revoke one first"));
- }
- let (link, token) = new_link(access, &clean_name(label), now())?;
- entry.links.push(link.clone());
- if let Err(e) = write_json(&path, &entry.links) {
- entry.links.pop();
- return Err(format!("couldn't keep the link: {e}"));
- }
- Ok(NewLink { link: link.info(file), token })
- }
-
- /// The link of `entry` whose token `token` is (every link is compared, in constant time, so
- /// timing doesn't tell which one matched).
- fn link_of(entry: &Entry, token: &str) -> Option {
- let presented = hash(token);
- let mut found = None;
- for (i, l) in entry.links.iter().enumerate() {
- if same_secret(&presented, &l.hash) {
- found = Some(i);
- }
- }
- found
- }
-
- /// These join codes no longer open anything: typing one says so.
- fn forget_codes(&mut self, codes: impl IntoIterator- ) {
- self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
- let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
- self.gone_codes.drain(..extra);
- }
-
- /// Where the link with the join code `key` is: its file, its place among the file's links
- /// and its token (every code is compared, so timing doesn't tell which one matched).
- fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
- let mut found = None;
- for (id, e) in &self.files {
- for (i, l) in e.links.iter().enumerate() {
- if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
- found = Some((id.clone(), i, token));
- }
- }
- }
- found
- }
-
- /// A new join code for the link of `file` whose token is `token` (it takes the place of the
- /// link's earlier code): the code, shown this once.
- pub fn set_code(&mut self, file: &str, token: &str) -> Result
{
- let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
- // 45 random bits: one already in use is all but impossible, and never handed out twice.
- let (code, key) = loop {
- let code = codes::new_code()?;
- let key = codes::Key::new(&code);
- if self.find_code(&key).is_none() {
- break (code, key);
- }
- };
- let sealed = key.seal(token)?;
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
- let before = link.code.replace(sealed);
- let id = link.id.clone();
- if let Err(e) = write_json(&path, &entry.links) {
- if let Some(l) = entry.links.get_mut(at) {
- l.code = before;
- }
- return Err(format!("couldn't keep the join code: {e}"));
- }
- self.forget_codes(before);
- log::info!("event=code file={file} link={id}");
- Ok(code)
- }
-
- /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
- /// the address (see `abuse`), and nothing is looked up for an address that is locked out.
- pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
- let now = Instant::now();
- if let Some(left) = self.abuse.code_locked(ip, now) {
- return Err(format!(
- "Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
- left.as_secs() / 60 + 1
- ));
- }
- if !self.abuse.allow(ip, Action::Code, now) {
- return Err("Too many codes tried from your address: wait a minute and try again".into());
- }
- if !self.abuse.codes_open(now) {
- return Err(CODES_BUSY.into());
- }
- // Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
- let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
- let found = key.as_ref().and_then(|k| self.find_code(k));
- self.abuse.code_tried(ip, found.is_some(), now);
- match found {
- Some((file, _, token)) => {
- log::info!("event=code_used file={file} ip={ip}");
- Ok((file, token))
- }
- None => {
- let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
- Err(if gone { CODE_GONE } else { NO_CODE }.into())
- }
- }
- }
-
- /// Revoke a link.
- pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
- let path = self.dir(file).join(LINKS);
- let entry = self.entry(file)?;
- let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
- let gone = entry.links.remove(at);
- if let Err(e) = write_json(&path, &entry.links) {
- entry.links.insert(at, gone);
- return Err(format!("couldn't revoke the link: {e}"));
- }
- log::info!("revoked link {link} of file {file}");
- self.forget_codes(gone.code);
- Ok(())
- }
-
- /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
- /// longer served either way).
- pub fn remove(&mut self, file: &str) -> Result<(), String> {
- // Closed first: an open log would keep the folder from being deleted on Windows.
- let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
- self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
- drop(entry);
- let dir = self.dir(file);
- fs::remove_dir_all(&dir)
- .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
- log::info!("removed file {file}");
- Ok(())
- }
-
- /// A file's team library for `ip`, unless its version is `have`; within what one address and
- /// everyone may download (the size is known before anything is read).
- fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result {
- let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
- let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
- if have == Some(lib.version) {
- return Ok(LibraryReply::Unchanged { version: lib.version });
- }
- let path = library_file(&dir, lib.version);
- let size = fs::metadata(&path).map_or(0, |m| m.len());
- let now = Instant::now();
- if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
- return Err(BUSY_DOWNLOADS.into());
- }
- Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
- }
-
- /// Keep `archive` as a file's team library: it must read as a library (with the limits files
- /// using it read it with), be the file's library if it has one, and be a newer version.
- // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
- // move it to a worker if large libraries make that noticeable.
- pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result {
- let max = self.cfg.max_file_bytes;
- if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
- return Err(NO_PUBLIC_LIBRARY.into());
- }
- if archive.len() as u64 > max {
- return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
- }
- // Everything it unpacks to counts against the same cap, so a small archive can't expand
- // into a large library in memory.
- let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
- Ok(Some(lib)) => lib,
- Ok(None) => return Err("that isn't a published library".into()),
- Err(e) => return Err(format!("the library can't be read: {e}")),
- };
- if lib.doc.node_count() > self.cfg.core.max_nodes {
- return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
- }
- let m = &lib.manifest;
- let dir = self.dir(file);
- let entry = self.entry(file)?;
- if let Some(old) = &entry.meta.library {
- if old.id != m.id {
- return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
- }
- if m.version <= old.version {
- return Err(format!("version {} is on the server already: publish again to follow it", old.version));
- }
- // A jump would let a client use up the numbers and leave no newer version to publish.
- if m.version > old.version.saturating_add(1) {
- return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
- }
- } else if m.version > MAX_FIRST_LIBRARY_VERSION {
- return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
- }
- // Each version has a file of its own, written before the description names it: a failed
- // description write or a crash in between leaves the previous version served whole, never
- // the new bytes under the old number.
- let path = dir.join(library_name(m.version));
- disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
- let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
- if let Err(e) = write_json(&dir.join(META), &entry.meta) {
- // Never served (the description doesn't name it); the next publish of it replaces it.
- if let Err(r) = fs::remove_file(&path) {
- log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
- }
- entry.meta.library = before;
- return Err(format!("couldn't keep the library: {e}"));
- }
- // The previous version isn't served any more.
- if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
- log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
- }
- log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
- Ok(m.version)
- }
-
- /// A library's usage reports in memory (read from disk the first time; a damaged list starts
- /// over: it is only statistics). `None` when the file has no library.
- fn usage_of(&mut self, file: &str) -> Result, String> {
- if self.entry(file)?.meta.library.is_none() {
- return Ok(None);
- }
- if !self.usage.contains_key(file) {
- let path = self.dir(file).join(USAGE);
- let list = if path.exists() {
- disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
- } else {
- Vec::new()
- };
- self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
- }
- Ok(self.usage.get_mut(file))
- }
-
- /// The usage reports kept for a file's library (none when it has no library).
- pub fn library_usage(&mut self, file: &str) -> Result, String> {
- Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
- }
-
- /// Keep how one file uses a file's library (in memory; written when it changed, at most every
- /// [`USAGE_WRITE_EVERY`] seconds).
- pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
- let name = clean_name(&usage.file);
- if name.is_empty() {
- return Ok(());
- }
- let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
- components.sort();
- components.dedup_by_key(|c| c.0);
- components.truncate(MAX_REPORTED);
- let Some(u) = self.usage_of(file)? else { return Ok(()) };
- match u.list.iter_mut().find(|f| f.usage.file == name) {
- Some(f) if f.usage.components == components => return Ok(()),
- Some(f) => {
- f.usage.components = components;
- f.updated = now();
- }
- None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
- }
- if u.list.len() > MAX_USERS {
- u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
- u.list.truncate(MAX_USERS);
- }
- u.dirty = true;
- if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
- self.write_usage(false);
- }
- Ok(())
- }
-
- /// Write the usage reports that changed (`all`: whenever they were last written).
- fn write_usage(&mut self, all: bool) {
- let t = now();
- let root = self.root.join(FILES);
- for (file, u) in &mut self.usage {
- if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
- if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
- log::warn!("file {file}: library usage couldn't be kept: {e}");
- }
- (u.dirty, u.written) = (false, t);
- }
- }
- }
-
- /// Whether `file` has room for about `n` more bytes: its cap, and for a file published
- /// without the key its address's share and everyone's. A log that took it over its cap is
- /// compacted first (the state may be smaller than its history).
- fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
- if let Some(why) = self.low_disk() {
- return Err(why);
- }
- let max = self.cfg.max_file_bytes;
- let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
- // A compaction to make room at most once a minute per file (it rewrites the whole file).
- let t = now();
- let may_compact = self.files.get(file).is_some_and(|e| {
- t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
- });
- if over(self) && may_compact {
- if let Some(e) = self.files.get_mut(file) {
- e.forced = t;
- }
- self.compact(file, server);
- }
- if over(self) {
- return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
- }
- let h = &self.cfg.hosting;
- let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
- let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
- for (times, _, used) in self.per_block(&ip) {
- let cap = max_ip.saturating_mul(u64::from(times));
- if used.saturating_add(n) > cap {
- return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
- }
- }
- let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
- if used_all.saturating_add(n) > max_all {
- return Err("this server is full: that change wasn't kept".into());
- }
- Ok(())
- }
-
- /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
- fn load(&mut self, file: &str) -> Result {
- self.touch(file);
- let dir = self.dir(file);
- let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
- let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
- let mut issues = Vec::new();
- let mut fresh_log = true;
- let mut kept = 0;
- let log_path = dir.join(LOG);
- // A log that's there but can't be read (an IO error, or over a lowered cap) may hold
- // confirmed edits: the file isn't opened, and the log is left as it is for another try.
- let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
- Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
- r => r,
- };
- // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
- let read = match read.map(|b| disk::read_log(&b)) {
- Ok(Err(why)) => {
- let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
- fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
- Err(format!("{why}; it was moved aside to {}", aside.display()))
- }
- Ok(Ok(log)) => Ok(log),
- Err(why) => Err(why),
- };
- match read {
- Ok(log) if log.generation == generation => {
- fresh_log = false;
- for entry in log.entries {
- if let Err(e) = server.replay(entry) {
- issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
- fresh_log = true;
- break;
- }
- kept += 1;
- }
- if let Some(why) = log.damaged {
- issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
- fresh_log = true;
- }
- }
- // From before a compaction that was cut short, the snapshot has it all; or a generation
- // field damaged, which no checksum covers. Replaying tells them apart without trusting
- // it: entries the snapshot holds are skipped, so only edits it lacks change anything.
- Ok(log) => {
- let before = server.seq();
- let mut stopped = None;
- for entry in log.entries {
- if let Err(e) = server.replay(entry) {
- stopped = Some(e);
- break;
- }
- }
- let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
- if recovered > 0 {
- issues.push(format!(
- "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
- {recovered} changes the snapshot didn't; they were recovered"
- ));
- }
- if let Some(e) = stopped {
- issues.push(format!(
- "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
- making sense after {recovered} newer changes ({e}); the rest was dropped"
- ));
- }
- }
- Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
- }
- for i in issues {
- log::warn!("{i}");
- self.note(i);
- }
- server.journaling(true);
- let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
- let log = match (fresh_log, file_handle) {
- (false, Ok(f)) => {
- let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
- // Replayed entries make it worth compacting once everyone has left.
- Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
- }
- // A new snapshot and log of the next generation, so appends start clean.
- _ => {
- let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
- self.rewrite(file, next, &server).map_err(|e| e.why)?
- }
- };
- self.entry(file)?.log = Some(log);
- Ok(server)
- }
-
- /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
- /// document (recent batches for reconnecting clients, deletions), which may take it over.
- fn snapshot_cap(&self) -> u64 {
- self.cfg.max_file_bytes.saturating_mul(2)
- }
-
- /// Write `server`'s state as snapshot `generation` and start an empty log of it.
- fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result {
- let dir = self.dir(file);
- let kept = |why: String| RewriteError { why, snapshot_replaced: false };
- let state = server.state().map_err(|e| kept(e.to_string()))?;
- let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
- // Never replace a readable snapshot with one that can't be opened again: the old snapshot
- // and log still hold everything.
- if bytes.len() as u64 > self.snapshot_cap() {
- return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
- }
- if bytes.len() as u64 > self.cfg.max_file_bytes {
- log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
- }
- disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
- // From here the old log no longer goes with the snapshot on disk.
- let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
- disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
- let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
- if let Ok(e) = self.entry(file) {
- e.bytes = (bytes.len() + LOG_HEADER) as u64;
- e.meta.seq = server.seq();
- let meta = e.meta.clone();
- if let Err(err) = write_json(&dir.join(META), &meta) {
- log::warn!("file {file}: couldn't update {META}: {err}");
- }
- }
- Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
- }
-
- /// Compactions so far.
- pub fn compactions(&self) -> u64 {
- self.compactions
- }
-
- /// Why nothing more may be written when the data directory's disk is nearly full (less free
- /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
- /// can't be told, nothing is refused.
- fn low_disk(&mut self) -> Option {
- let now = Instant::now();
- let free = match self.free {
- Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
- _ => {
- let free = disk::free_bytes(&self.root);
- self.free = Some((now, free));
- free
- }
- };
- free.filter(|f| *f < self.cfg.min_free_bytes)
- .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
- }
-
- /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
- fn note(&mut self, problem: String) {
- if self.problems.len() >= MAX_PROBLEMS {
- self.problems.remove(0);
- }
- self.problems.push(problem);
- }
-
- /// Compact an open file: its state as the next snapshot, an empty log.
- fn compact(&mut self, file: &str, server: &Server) {
- self.compactions += 1;
- let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
- let Some(next) = generation.checked_add(1) else {
- log::error!("file {file}: compaction failed: the file's generation can't go any higher");
- return;
- };
- match self.rewrite(file, next, server) {
- Ok(log) => {
- if let Ok(e) = self.entry(file) {
- e.log = Some(log);
- }
- log::debug!("compacted file {file}");
- }
- // The new snapshot holds everything, but a restart would drop whatever is appended to
- // the old log now (its generation no longer matches): the file closes, the hub hears
- // it at the next flush, and it opens again from the new snapshot.
- Err(RewriteError { why, snapshot_replaced: true }) => {
- self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
- log::error!("file {file}: compaction was cut short: {why}");
- if let Some(e) = self.files.get_mut(file) {
- e.log = None;
- }
- self.cut_short.push(file.to_string());
- }
- // The old snapshot and log still hold everything: carry on appending.
- Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
- }
- }
-
- /// Write and sync what's pending in every open log; the files whose log couldn't be written.
- /// Those are closed here (their pending records dropped: nobody was told of them) and open
- /// again from what is on disk.
- fn flush_all(&mut self) -> Vec {
- let mut failed = std::mem::take(&mut self.cut_short);
- for (id, e) in &mut self.files {
- if let Some(log) = e.log.as_mut() {
- match flush_log(id, log) {
- Ok(n) => e.bytes += n,
- Err(()) => {
- e.log = None;
- failed.push(id.clone());
- }
- }
- }
- }
- failed
- }
}
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
@@ -1388,38 +558,6 @@ struct RewriteError {
snapshot_replaced: bool,
}
-/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
-/// can't be trusted to hold them).
-fn flush_log(id: &str, log: &mut Log) -> Result {
- if log.pending.is_empty() {
- return Ok(0);
- }
- match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
- Ok(()) => {
- let n = log.pending.len() as u64;
- log.bytes += n;
- log.pending.clear();
- Ok(n)
- }
- Err(e) => {
- log::error!("file {id}: couldn't write its log: {e}");
- Err(())
- }
- }
-}
-
-/// The name of the file holding version `version` of a file's team library.
-pub fn library_name(version: u64) -> String {
- format!("library-{version}.bin")
-}
-
-/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
-/// version published before each version had one.
-fn library_file(dir: &Path, version: u64) -> PathBuf {
- let path = dir.join(library_name(version));
- if path.exists() { path } else { dir.join(LIBRARY) }
-}
-
fn write_json(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
@@ -1430,239 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String),
let token = token::secret()?;
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token))
}
-
-impl Backend for Store {
- fn admit(&mut self, file: &str, token: &str) -> Result {
- let entry = self.entry(file)?;
- let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
- link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
- .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
- }
-
- fn open(&mut self, file: &str) -> Result {
- self.load(file).map_err(|e| {
- let why = format!("file {file} can't be opened: {e}");
- log::error!("{why}");
- self.note(why);
- "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
- })
- }
-
- fn changed(&mut self, file: &str, server: &mut Server) {
- let journal = server.take_journal();
- if journal.is_empty() {
- return;
- }
- let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
- let Some(e) = self.files.get_mut(file) else { return };
- let Some(log) = e.log.as_mut() else { return };
- for j in &journal {
- match disk::record(j) {
- Ok(r) => log.pending.extend(r),
- Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
- }
- }
- log.entries += journal.len();
- log.dirty = true;
- e.meta.updated = now();
- if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
- // The snapshot holds what's pending too.
- self.compact(file, server);
- }
- }
-
- fn flush(&mut self) -> Vec {
- self.flush_all()
- }
-
- fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
- self.room_for(file, server, bytes as u64)
- }
-
- fn idle(&mut self, file: &str, server: &mut Server) -> bool {
- let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
- let _ = flush_log(file, log);
- log.dirty
- });
- if dirty {
- self.compact(file, server);
- }
- // It closes anyway, everything in its snapshot: a compaction cut short needs no report.
- self.cut_short.retain(|f| f != file);
- if let Some(e) = self.files.get_mut(file) {
- e.log = None;
- }
- // Everyone just left: idle time counts from now.
- self.touch(file);
- true
- }
-
- fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
- self.write_usage(true);
- self.flush_all();
- for (file, server) in open {
- if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
- self.compact(file, server);
- }
- }
- }
-
- fn manage(&mut self, key: &str) -> Result<(), String> {
- match same_secret(key, &self.key) {
- true => Ok(()),
- false if self.cfg.hosting.open => {
- Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
- }
- false => Err("wrong server key".into()),
- }
- }
-
- fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
- if now() >= self.next_sweep {
- self.sweep();
- self.write_usage(false);
- }
- if self.blocked.contains(&ip) {
- return Err("this server doesn't accept connections from your address".into());
- }
- if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
- return Err("too many connections from your address lately: wait a minute and try again".into());
- }
- Ok(())
- }
-
- fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
- // Cursors and cursor chat (many people behind one address each send ~30 a second while
- // they move) have a budget of their own, beside everything else.
- let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
- self.abuse.allow(ip, what, Instant::now())
- }
-
- fn hosting(&mut self, file: &str) -> Option {
- Some(self.host_info(Some(file)))
- }
-
- fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result {
- if file.is_empty() && owner.is_empty() {
- return Ok(self.host_info(None));
- }
- if !self.owns(file, owner) {
- return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
- }
- Ok(self.host_info(Some(file)))
- }
-
- fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled {
- let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
- let file = match &req {
- Admin::Publish { name, snapshot } => {
- return match self.publish_open(ip, name, snapshot) {
- Ok((file, edit, view, owner)) => {
- let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
- AdminReply::Hosted { file, edit, view, owner, expires }.into()
- }
- Err(e) => {
- log::info!("event=publish_refused ip={ip} reason={e:?}");
- Handled::from(AdminReply::Failed { reason: e })
- }
- };
- }
- Admin::Owned { files } => return self.owned(files).into(),
- Admin::Links { file }
- | Admin::CreateLink { file, .. }
- | Admin::Revoke { file, .. }
- | Admin::Rename { file, .. }
- | Admin::Code { file, .. }
- | Admin::Remove { file } => file,
- _ => return failed("that needs the server key"),
- };
- if owns != Some(file.as_str()) {
- return failed("an owner token manages only its own file");
- }
- self.admin(req, open)
- }
-
- fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
- self.keep_report(file, link, ip, reason)
- }
-
- fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled {
- let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
- match req {
- Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
- Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
- Err(e) => failed(e),
- },
- Admin::Files => AdminReply::Files(self.list(open)).into(),
- Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
- Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
- Admin::Revoke { file, link } => match self.revoke(&file, &link) {
- Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
- Err(e) => failed(e),
- },
- Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
- Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
- Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
- Admin::Block { ip } => match self.block(&ip, true) {
- Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
- Err(e) => failed(e),
- },
- Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
- Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
- Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
- Admin::Owned { files } => self.owned(&files).into(),
- // The hub removes people itself (it knows who is connected); it never asks the store.
- Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
- Admin::Remove { file } => {
- if !self.files.contains_key(&file) {
- return failed("this file isn't on the server (it may have been removed)".into());
- }
- let reply = match self.remove(&file) {
- Ok(()) => AdminReply::Done,
- Err(reason) => AdminReply::Failed { reason },
- };
- Handled { reply, revoked: None, removed: Some(file), blocked: None }
- }
- }
- }
-
- fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
- let failed = |reason: String| LibraryReply::Failed { reason };
- match req {
- LibraryReq::Fetch { have, usage } => {
- if let Some(u) = usage
- && let Err(e) = self.report_usage(file, u)
- {
- log::warn!("file {file}: a library usage report couldn't be kept: {e}");
- }
- self.fetch_library(ip, file, have).unwrap_or_else(failed)
- }
- LibraryReq::Publish { .. } if access != Access::Edit => {
- failed("you can only view this file: publishing its library needs an edit link".into())
- }
- LibraryReq::Publish { archive } => {
- self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
- }
- LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
- }
- }
-
- fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
- // One address's share first (before everyone's budget is spent or anything is read).
- if let PluginReq::Fetch { id, version } = &req
- && let Some(size) = self.directory.size_of(id, version.as_deref())
- && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
- {
- return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
- }
- self.directory.handle(publisher, req)
- }
-
- fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
- self.resolve_code(ip, code)
- }
-
- fn closing(&self) -> Closing {
- Closing::Restarting
- }
-}
diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs
new file mode 100644
index 00000000..af9b8bbb
--- /dev/null
+++ b/apps/figcraft-server/src/store/backend.rs
@@ -0,0 +1,242 @@
+use super::journal::flush_log;
+use super::*;
+
+impl Backend for Store {
+ fn admit(&mut self, file: &str, token: &str) -> Result {
+ let entry = self.entry(file)?;
+ let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
+ link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
+ .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
+ }
+
+ fn open(&mut self, file: &str) -> Result {
+ self.load(file).map_err(|e| {
+ let why = format!("file {file} can't be opened: {e}");
+ log::error!("{why}");
+ self.note(why);
+ "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
+ })
+ }
+
+ fn changed(&mut self, file: &str, server: &mut Server) {
+ let journal = server.take_journal();
+ if journal.is_empty() {
+ return;
+ }
+ let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
+ let Some(e) = self.files.get_mut(file) else { return };
+ let Some(log) = e.log.as_mut() else { return };
+ for j in &journal {
+ match disk::record(j) {
+ Ok(r) => log.pending.extend(r),
+ Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
+ }
+ }
+ log.entries += journal.len();
+ log.dirty = true;
+ e.meta.updated = now();
+ if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
+ // The snapshot holds what's pending too.
+ self.compact(file, server);
+ }
+ }
+
+ fn flush(&mut self) -> Vec {
+ self.flush_all()
+ }
+
+ fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
+ self.room_for(file, server, bytes as u64)
+ }
+
+ fn idle(&mut self, file: &str, server: &mut Server) -> bool {
+ let compact = self.files.get_mut(file).is_some_and(|entry| {
+ let snapshot_seq = entry.meta.seq;
+ entry.log.as_mut().is_some_and(|log| {
+ // Issued client ids and refusals are durable in the log; only edits need a
+ // new image-bearing snapshot. Still try a snapshot if syncing the log failed.
+ flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
+ })
+ });
+ if compact {
+ self.compact(file, server);
+ }
+ // It closes anyway, with its state in the snapshot and durable log.
+ self.cut_short.retain(|f| f != file);
+ if let Some(e) = self.files.get_mut(file) {
+ e.log = None;
+ }
+ // Everyone just left: idle time counts from now.
+ self.touch(file);
+ true
+ }
+
+ fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
+ self.write_usage(true);
+ self.flush_all();
+ for (file, server) in open {
+ if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
+ self.compact(file, server);
+ }
+ }
+ }
+
+ fn manage(&mut self, key: &str) -> Result<(), String> {
+ match same_secret(key, &self.key) {
+ true => Ok(()),
+ false if self.cfg.hosting.open => {
+ Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
+ }
+ false => Err("wrong server key".into()),
+ }
+ }
+
+ fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
+ if now() >= self.next_sweep {
+ self.sweep();
+ self.write_usage(false);
+ }
+ if self.blocked.contains(&ip) {
+ return Err("this server doesn't accept connections from your address".into());
+ }
+ if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
+ return Err("too many connections from your address lately: wait a minute and try again".into());
+ }
+ Ok(())
+ }
+
+ fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
+ // Cursors and cursor chat (many people behind one address each send ~30 a second while
+ // they move) have a budget of their own, beside everything else.
+ let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
+ self.abuse.allow(ip, what, Instant::now())
+ }
+
+ fn hosting(&mut self, file: &str) -> Option {
+ Some(self.host_info(Some(file)))
+ }
+
+ fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result {
+ if file.is_empty() && owner.is_empty() {
+ return Ok(self.host_info(None));
+ }
+ if !self.owns(file, owner) {
+ return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
+ }
+ Ok(self.host_info(Some(file)))
+ }
+
+ fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled {
+ let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
+ let file = match &req {
+ Admin::Publish { name, snapshot } => {
+ return match self.publish_open(ip, name, snapshot) {
+ Ok((file, edit, view, owner)) => {
+ let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
+ AdminReply::Hosted { file, edit, view, owner, expires }.into()
+ }
+ Err(e) => {
+ log::info!("event=publish_refused ip={ip} reason={e:?}");
+ Handled::from(AdminReply::Failed { reason: e })
+ }
+ };
+ }
+ Admin::Owned { files } => return self.owned(files).into(),
+ Admin::Links { file }
+ | Admin::CreateLink { file, .. }
+ | Admin::Revoke { file, .. }
+ | Admin::Rename { file, .. }
+ | Admin::Code { file, .. }
+ | Admin::Remove { file } => file,
+ _ => return failed("that needs the server key"),
+ };
+ if owns != Some(file.as_str()) {
+ return failed("an owner token manages only its own file");
+ }
+ self.admin(req, open)
+ }
+
+ fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
+ self.keep_report(file, link, ip, reason)
+ }
+
+ fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled {
+ let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
+ match req {
+ Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
+ Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
+ Err(e) => failed(e),
+ },
+ Admin::Files => AdminReply::Files(self.list(open)).into(),
+ Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
+ Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
+ Admin::Revoke { file, link } => match self.revoke(&file, &link) {
+ Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
+ Err(e) => failed(e),
+ },
+ Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
+ Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
+ Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
+ Admin::Block { ip } => match self.block(&ip, true) {
+ Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
+ Err(e) => failed(e),
+ },
+ Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
+ Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
+ Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
+ Admin::Owned { files } => self.owned(&files).into(),
+ // The hub removes people itself (it knows who is connected); it never asks the store.
+ Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
+ Admin::Remove { file } => {
+ if !self.files.contains_key(&file) {
+ return failed("this file isn't on the server (it may have been removed)".into());
+ }
+ let reply = match self.remove(&file) {
+ Ok(()) => AdminReply::Done,
+ Err(reason) => AdminReply::Failed { reason },
+ };
+ Handled { reply, revoked: None, removed: Some(file), blocked: None }
+ }
+ }
+ }
+
+ fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
+ let failed = |reason: String| LibraryReply::Failed { reason };
+ match req {
+ LibraryReq::Fetch { have, usage } => {
+ if let Some(u) = usage
+ && let Err(e) = self.report_usage(file, u)
+ {
+ log::warn!("file {file}: a library usage report couldn't be kept: {e}");
+ }
+ self.fetch_library(ip, file, have).unwrap_or_else(failed)
+ }
+ LibraryReq::Publish { .. } if access != Access::Edit => {
+ failed("you can only view this file: publishing its library needs an edit link".into())
+ }
+ LibraryReq::Publish { archive } => {
+ self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
+ }
+ LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
+ }
+ }
+
+ fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
+ // One address's share first (before everyone's budget is spent or anything is read).
+ if let PluginReq::Fetch { id, version } = &req
+ && let Some(size) = self.directory.size_of(id, version.as_deref())
+ && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
+ {
+ return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
+ }
+ self.directory.handle(publisher, req)
+ }
+
+ fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
+ self.resolve_code(ip, code)
+ }
+
+ fn closing(&self) -> Closing {
+ Closing::Restarting
+ }
+}
diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs
new file mode 100644
index 00000000..53ae9c87
--- /dev/null
+++ b/apps/figcraft-server/src/store/files.rs
@@ -0,0 +1,300 @@
+use super::*;
+
+impl Store {
+ /// Keep a document (its snapshot) as a new file with an edit link and a view link.
+ pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
+ self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
+ }
+
+ /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
+ /// caps it gets, and the files and bytes kept by the files published from addresses in it. An
+ /// address that doesn't parse counts as itself only, ×1, by its exact text.
+ pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
+ let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
+ let kept: Vec<(&str, Vec<_>, u64)> = self
+ .files
+ .iter()
+ .filter_map(|(id, e)| {
+ let from = e.meta.ip.as_deref()?;
+ Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
+ })
+ .collect();
+ let sum = |mine: &mut dyn Iterator- , u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
+ let blocks = blocks_of(ip);
+ if blocks.is_empty() {
+ let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
+ return vec![(1, files, bytes)];
+ }
+ blocks
+ .into_iter()
+ .map(|(block, times)| {
+ let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
+ (times, files, bytes)
+ })
+ .collect()
+ }
+
+ /// Keep a document published without the key from `ip`, within the caps on what one address
+ /// and everyone may keep: the file, its links and its owner token.
+ pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
+ let h = &self.cfg.hosting;
+ let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
+ if !open {
+ return Err(KEY_ONLY.into());
+ }
+ if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
+ return Err("too many files published from your address lately: try again in an hour".into());
+ }
+ let blocks = self.per_block(&ip.to_string());
+ for &(times, files, _) in &blocks {
+ if files >= max_files.saturating_mul(times as usize) {
+ return Err(format!(
+ "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
+ ));
+ }
+ }
+ let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
+ let fits = |n: u64| {
+ for &(times, _, used) in &blocks {
+ let cap = max_ip.saturating_mul(u64::from(times));
+ if used.saturating_add(n) > cap {
+ return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
+ }
+ }
+ if used_all.saturating_add(n) > max_all {
+ return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
+ }
+ Ok(())
+ };
+ let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
+ Ok((info, edit, view, owner.unwrap_or_default()))
+ }
+
+ /// Keep a document as a new file: published without the key from `ip` (it gets an owner
+ /// token), else by the operator. `fits` checks its size against the caps that apply.
+ fn keep(
+ &mut self,
+ name: &str,
+ snapshot: &[Change],
+ ip: Option
,
+ fits: &dyn Fn(u64) -> Result<(), String>,
+ ) -> Result<(FileInfo, NewLink, NewLink, Option), String> {
+ if self.files.len() >= self.cfg.max_files {
+ return Err(format!("the server keeps at most {} files", self.cfg.max_files));
+ }
+ if let Some(why) = self.low_disk() {
+ return Err(why);
+ }
+ let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
+ if replica.document().node_count() > self.cfg.core.max_nodes {
+ return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
+ }
+ let mut doc = replica.document().clone();
+ let name = match clean_name(name) {
+ n if n.is_empty() => doc.name.clone(),
+ n => n,
+ };
+ doc.name = name.clone();
+ let server = Server::new(doc, "", self.cfg.core);
+ let state = server.state().map_err(|e| e.to_string())?;
+ let bytes = disk::encode_snapshot(1, &state)?;
+ if bytes.len() as u64 > self.cfg.max_file_bytes {
+ return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
+ }
+ fits(bytes.len() as u64)?;
+ let id = loop {
+ let id = token::file_id()?;
+ if !self.files.contains_key(&id) && !self.dir(&id).exists() {
+ break id;
+ }
+ };
+ let t = now();
+ let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
+ let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
+ let owner = ip.map(|_| token::secret()).transpose()?;
+ let meta =
+ Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
+ let dir = self.dir(&id);
+ let written = fs::create_dir_all(&dir)
+ .map_err(|e| e.to_string())
+ .and_then(|()| {
+ disk::sync_dir(Some(&self.root.join(FILES)));
+ disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
+ })
+ .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
+ .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
+ // Last: a folder without its description is an upload cut short.
+ .and_then(|()| write_json(&dir.join(META), &meta));
+ if let Err(e) = written {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(format!("couldn't keep the file: {e}"));
+ }
+ let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
+ self.files.insert(id.clone(), entry);
+ let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
+ match ip {
+ Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
+ None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
+ }
+ Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
+ }
+
+ /// The files kept, with what the operator moderates by.
+ pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec {
+ self.files
+ .iter()
+ .map(|(id, e)| FileRow {
+ info: self.info(id, e, open(id)),
+ opened: e.meta.opened,
+ expires: self.expires(&e.meta),
+ open: e.meta.owner.is_some(),
+ ip: e.meta.ip.clone().unwrap_or_default(),
+ reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
+ })
+ .collect()
+ }
+
+ /// Owner token `owner` manages file `file` (one published without the key).
+ pub(super) fn owns(&self, file: &str, owner: &str) -> bool {
+ let presented = hash(owner);
+ self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
+ }
+
+ /// What became of the files these `(file id, owner token)` pairs own: each one's name, links
+ /// and expiry, or `None` when that token manages no file of that id here (which doesn't say
+ /// whether the id exists). Asking opens nothing: it doesn't count as using a file.
+ pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
+ if files.len() > MAX_OWNED {
+ return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
+ }
+ let one = |(file, owner): &(String, String)| {
+ let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
+ Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
+ };
+ AdminReply::Owned(files.iter().map(one).collect())
+ }
+
+ /// Rename a file (its listing; the document inside keeps its own name).
+ pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
+ let name = clean_name(name);
+ if name.is_empty() {
+ return Err("give the file a name".into());
+ }
+ let path = self.dir(file).join(META);
+ let entry = self.entry(file)?;
+ let before = std::mem::replace(&mut entry.meta.name, name);
+ if let Err(e) = write_json(&path, &entry.meta) {
+ entry.meta.name = before;
+ return Err(format!("couldn't rename the file: {e}"));
+ }
+ Ok(())
+ }
+
+ /// Delete the files published without the key that nobody opened for the idle time (not
+ /// those open now); their ids.
+ pub fn sweep(&mut self) -> Vec {
+ let t = now();
+ self.next_sweep = t.saturating_add(SWEEP_EVERY);
+ let due: Vec =
+ self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
+ for id in &due {
+ match self.remove(id) {
+ Ok(()) => log::info!("event=expired file={id}"),
+ Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
+ }
+ }
+ due
+ }
+
+ /// Make a file look unused for `secs` more (tests: expiry without waiting).
+ #[cfg(test)]
+ pub fn age(&mut self, file: &str, secs: u64) {
+ let path = self.dir(file).join(META);
+ if let Some(e) = self.files.get_mut(file) {
+ let m = &mut e.meta;
+ (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
+ let _ = write_json(&path, m);
+ }
+ }
+
+ /// Make an open file's log refuse writes (tests: a full or failing disk).
+ #[cfg(test)]
+ pub fn break_log(&mut self, file: &str) {
+ let path = self.dir(file).join(LOG);
+ if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
+ && let Ok(f) = File::open(path)
+ {
+ log.file = f;
+ }
+ }
+
+ /// Note that a file was opened now (it is used: it doesn't expire).
+ pub(super) fn touch(&mut self, file: &str) {
+ let path = self.dir(file).join(META);
+ if let Some(e) = self.files.get_mut(file) {
+ e.meta.opened = now();
+ if let Err(err) = write_json(&path, &e.meta) {
+ log::warn!("file {file}: couldn't update {META}: {err}");
+ }
+ }
+ }
+
+ /// What the server says about its hosting (with `file`'s expiry).
+ pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
+ let h = &self.cfg.hosting;
+ let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
+ HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
+ }
+
+ /// Block or unblock an address (an IPv6 address: its /64); the address as kept.
+ pub fn block(&mut self, ip: &str, on: bool) -> Result {
+ let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
+ if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
+ return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
+ }
+ let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
+ if changed {
+ let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect();
+ write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
+ log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
+ }
+ Ok(ip)
+ }
+
+ /// The blocked addresses.
+ pub fn blocked(&self) -> Vec {
+ self.blocked.iter().map(IpAddr::to_string).collect()
+ }
+
+ /// The reports kept, oldest first.
+ pub fn reports(&self) -> &[ReportInfo] {
+ &self.reports
+ }
+
+ /// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
+ pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
+ let reason = clean_reason(reason);
+ if reason.is_empty() {
+ return Err("say what is wrong with the file".into());
+ }
+ let name = self.entry(file)?.meta.name.clone();
+ if !self.abuse.allow(ip, Action::Report, Instant::now()) {
+ return Err("too many reports from your address lately: try again in an hour".into());
+ }
+ self.reports.push(ReportInfo {
+ at: now(),
+ file: file.to_string(),
+ name,
+ link: link.unwrap_or_default().to_string(),
+ ip: ip.to_string(),
+ reason,
+ });
+ if self.reports.len() > MAX_REPORTS {
+ let extra = self.reports.len() - MAX_REPORTS;
+ self.reports.drain(..extra);
+ }
+ write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
+ log::info!("event=report file={file} ip={ip}");
+ Ok(())
+ }
+}
diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs
new file mode 100644
index 00000000..e3983543
--- /dev/null
+++ b/apps/figcraft-server/src/store/journal.rs
@@ -0,0 +1,270 @@
+use super::*;
+
+impl Store {
+ /// Whether `file` has room for about `n` more bytes: its cap, and for a file published
+ /// without the key its address's share and everyone's. A log that took it over its cap is
+ /// compacted first (the state may be smaller than its history).
+ pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
+ if let Some(why) = self.low_disk() {
+ return Err(why);
+ }
+ let max = self.cfg.max_file_bytes;
+ let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
+ // A compaction to make room at most once a minute per file (it rewrites the whole file).
+ let t = now();
+ let may_compact = self.files.get(file).is_some_and(|e| {
+ t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
+ });
+ if over(self) && may_compact {
+ if let Some(e) = self.files.get_mut(file) {
+ e.forced = t;
+ }
+ self.compact(file, server);
+ }
+ if over(self) {
+ return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
+ }
+ let h = &self.cfg.hosting;
+ let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
+ let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
+ for (times, _, used) in self.per_block(&ip) {
+ let cap = max_ip.saturating_mul(u64::from(times));
+ if used.saturating_add(n) > cap {
+ return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
+ }
+ }
+ let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
+ if used_all.saturating_add(n) > max_all {
+ return Err("this server is full: that change wasn't kept".into());
+ }
+ Ok(())
+ }
+
+ /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
+ pub(super) fn load(&mut self, file: &str) -> Result {
+ self.touch(file);
+ let dir = self.dir(file);
+ let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
+ let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
+ let mut issues = Vec::new();
+ let mut fresh_log = true;
+ let mut kept = 0;
+ let log_path = dir.join(LOG);
+ // A log that's there but can't be read (an IO error, or over a lowered cap) may hold
+ // confirmed edits: the file isn't opened, and the log is left as it is for another try.
+ let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
+ Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
+ r => r,
+ };
+ // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
+ let read = match read.map(|b| disk::read_log(&b)) {
+ Ok(Err(why)) => {
+ let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
+ fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
+ Err(format!("{why}; it was moved aside to {}", aside.display()))
+ }
+ Ok(Ok(log)) => Ok(log),
+ Err(why) => Err(why),
+ };
+ match read {
+ Ok(log) if log.generation == generation => {
+ fresh_log = false;
+ for entry in log.entries {
+ if let Err(e) = server.replay(entry) {
+ issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
+ fresh_log = true;
+ break;
+ }
+ kept += 1;
+ }
+ if let Some(why) = log.damaged {
+ issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
+ fresh_log = true;
+ }
+ }
+ // From before a compaction that was cut short, the snapshot has it all; or a generation
+ // field damaged, which no checksum covers. Replaying tells them apart without trusting
+ // it: entries the snapshot holds are skipped, so only edits it lacks change anything.
+ Ok(log) => {
+ let before = server.seq();
+ let mut stopped = None;
+ for entry in log.entries {
+ if let Err(e) = server.replay(entry) {
+ stopped = Some(e);
+ break;
+ }
+ }
+ let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
+ if recovered > 0 {
+ issues.push(format!(
+ "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
+ {recovered} changes the snapshot didn't; they were recovered"
+ ));
+ }
+ if let Some(e) = stopped {
+ issues.push(format!(
+ "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
+ making sense after {recovered} newer changes ({e}); the rest was dropped"
+ ));
+ }
+ }
+ Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
+ }
+ for i in issues {
+ log::warn!("{i}");
+ self.note(i);
+ }
+ server.journaling(true);
+ let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
+ let log = match (fresh_log, file_handle) {
+ (false, Ok(f)) => {
+ let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
+ // Replayed entries make it worth compacting once everyone has left.
+ Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
+ }
+ // A new snapshot and log of the next generation, so appends start clean.
+ _ => {
+ let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
+ self.rewrite(file, next, &server).map_err(|e| e.why)?
+ }
+ };
+ self.entry(file)?.log = Some(log);
+ Ok(server)
+ }
+
+ /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
+ /// document (recent batches for reconnecting clients, deletions), which may take it over.
+ pub(super) fn snapshot_cap(&self) -> u64 {
+ self.cfg.max_file_bytes.saturating_mul(2)
+ }
+
+ /// Write `server`'s state as snapshot `generation` and start an empty log of it.
+ fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result {
+ let dir = self.dir(file);
+ let kept = |why: String| RewriteError { why, snapshot_replaced: false };
+ let state = server.state().map_err(|e| kept(e.to_string()))?;
+ let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
+ // Never replace a readable snapshot with one that can't be opened again: the old snapshot
+ // and log still hold everything.
+ if bytes.len() as u64 > self.snapshot_cap() {
+ return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
+ }
+ if bytes.len() as u64 > self.cfg.max_file_bytes {
+ log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
+ }
+ disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
+ // From here the old log no longer goes with the snapshot on disk.
+ let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
+ disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
+ let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
+ if let Ok(e) = self.entry(file) {
+ e.bytes = (bytes.len() + LOG_HEADER) as u64;
+ e.meta.seq = server.seq();
+ let meta = e.meta.clone();
+ if let Err(err) = write_json(&dir.join(META), &meta) {
+ log::warn!("file {file}: couldn't update {META}: {err}");
+ }
+ }
+ Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
+ }
+
+ /// Compactions so far.
+ pub fn compactions(&self) -> u64 {
+ self.compactions
+ }
+
+ /// Why nothing more may be written when the data directory's disk is nearly full (less free
+ /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
+ /// can't be told, nothing is refused.
+ pub(super) fn low_disk(&mut self) -> Option {
+ let now = Instant::now();
+ let free = match self.free {
+ Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
+ _ => {
+ let free = disk::free_bytes(&self.root);
+ self.free = Some((now, free));
+ free
+ }
+ };
+ free.filter(|f| *f < self.cfg.min_free_bytes)
+ .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
+ }
+
+ /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
+ pub(super) fn note(&mut self, problem: String) {
+ if self.problems.len() >= MAX_PROBLEMS {
+ self.problems.remove(0);
+ }
+ self.problems.push(problem);
+ }
+
+ /// Compact an open file: its state as the next snapshot, an empty log.
+ pub(super) fn compact(&mut self, file: &str, server: &Server) {
+ self.compactions += 1;
+ let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
+ let Some(next) = generation.checked_add(1) else {
+ log::error!("file {file}: compaction failed: the file's generation can't go any higher");
+ return;
+ };
+ match self.rewrite(file, next, server) {
+ Ok(log) => {
+ if let Ok(e) = self.entry(file) {
+ e.log = Some(log);
+ }
+ log::debug!("compacted file {file}");
+ }
+ // The new snapshot holds everything, but a restart would drop whatever is appended to
+ // the old log now (its generation no longer matches): the file closes, the hub hears
+ // it at the next flush, and it opens again from the new snapshot.
+ Err(RewriteError { why, snapshot_replaced: true }) => {
+ self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
+ log::error!("file {file}: compaction was cut short: {why}");
+ if let Some(e) = self.files.get_mut(file) {
+ e.log = None;
+ }
+ self.cut_short.push(file.to_string());
+ }
+ // The old snapshot and log still hold everything: carry on appending.
+ Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
+ }
+ }
+
+ /// Write and sync what's pending in every open log; the files whose log couldn't be written.
+ /// Those are closed here (their pending records dropped: nobody was told of them) and open
+ /// again from what is on disk.
+ pub(super) fn flush_all(&mut self) -> Vec {
+ let mut failed = std::mem::take(&mut self.cut_short);
+ for (id, e) in &mut self.files {
+ if let Some(log) = e.log.as_mut() {
+ match flush_log(id, log) {
+ Ok(n) => e.bytes += n,
+ Err(()) => {
+ e.log = None;
+ failed.push(id.clone());
+ }
+ }
+ }
+ }
+ failed
+ }
+}
+
+/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
+/// can't be trusted to hold them).
+pub(super) fn flush_log(id: &str, log: &mut Log) -> Result {
+ if log.pending.is_empty() {
+ return Ok(0);
+ }
+ match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
+ Ok(()) => {
+ let n = log.pending.len() as u64;
+ log.bytes += n;
+ log.pending.clear();
+ Ok(n)
+ }
+ Err(e) => {
+ log::error!("file {id}: couldn't write its log: {e}");
+ Err(())
+ }
+ }
+}
diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs
new file mode 100644
index 00000000..adb592db
--- /dev/null
+++ b/apps/figcraft-server/src/store/library.rs
@@ -0,0 +1,161 @@
+use super::*;
+
+impl Store {
+ /// A file's team library for `ip`, unless its version is `have`; within what one address and
+ /// everyone may download (the size is known before anything is read).
+ pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result {
+ let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
+ let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
+ if have == Some(lib.version) {
+ return Ok(LibraryReply::Unchanged { version: lib.version });
+ }
+ let path = library_file(&dir, lib.version);
+ let size = fs::metadata(&path).map_or(0, |m| m.len());
+ let now = Instant::now();
+ if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
+ return Err(BUSY_DOWNLOADS.into());
+ }
+ Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
+ }
+
+ /// Keep `archive` as a file's team library: it must read as a library (with the limits files
+ /// using it read it with), be the file's library if it has one, and be a newer version.
+ // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
+ // move it to a worker if large libraries make that noticeable.
+ pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result {
+ let max = self.cfg.max_file_bytes;
+ if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
+ return Err(NO_PUBLIC_LIBRARY.into());
+ }
+ if archive.len() as u64 > max {
+ return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
+ }
+ // Everything it unpacks to counts against the same cap, so a small archive can't expand
+ // into a large library in memory.
+ let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
+ Ok(Some(lib)) => lib,
+ Ok(None) => return Err("that isn't a published library".into()),
+ Err(e) => return Err(format!("the library can't be read: {e}")),
+ };
+ if lib.doc.node_count() > self.cfg.core.max_nodes {
+ return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
+ }
+ let m = &lib.manifest;
+ let dir = self.dir(file);
+ let entry = self.entry(file)?;
+ if let Some(old) = &entry.meta.library {
+ if old.id != m.id {
+ return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
+ }
+ if m.version <= old.version {
+ return Err(format!("version {} is on the server already: publish again to follow it", old.version));
+ }
+ // A jump would let a client use up the numbers and leave no newer version to publish.
+ if m.version > old.version.saturating_add(1) {
+ return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
+ }
+ } else if m.version > MAX_FIRST_LIBRARY_VERSION {
+ return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
+ }
+ // Each version has a file of its own, written before the description names it: a failed
+ // description write or a crash in between leaves the previous version served whole, never
+ // the new bytes under the old number.
+ let path = dir.join(library_name(m.version));
+ disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
+ let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
+ if let Err(e) = write_json(&dir.join(META), &entry.meta) {
+ // Never served (the description doesn't name it); the next publish of it replaces it.
+ if let Err(r) = fs::remove_file(&path) {
+ log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
+ }
+ entry.meta.library = before;
+ return Err(format!("couldn't keep the library: {e}"));
+ }
+ // The previous version isn't served any more.
+ if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
+ log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
+ }
+ log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
+ Ok(m.version)
+ }
+
+ /// A library's usage reports in memory (read from disk the first time; a damaged list starts
+ /// over: it is only statistics). `None` when the file has no library.
+ fn usage_of(&mut self, file: &str) -> Result, String> {
+ if self.entry(file)?.meta.library.is_none() {
+ return Ok(None);
+ }
+ if !self.usage.contains_key(file) {
+ let path = self.dir(file).join(USAGE);
+ let list = if path.exists() {
+ disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
+ } else {
+ Vec::new()
+ };
+ self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
+ }
+ Ok(self.usage.get_mut(file))
+ }
+
+ /// The usage reports kept for a file's library (none when it has no library).
+ pub fn library_usage(&mut self, file: &str) -> Result, String> {
+ Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
+ }
+
+ /// Keep how one file uses a file's library (in memory; written when it changed, at most every
+ /// [`USAGE_WRITE_EVERY`] seconds).
+ pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
+ let name = clean_name(&usage.file);
+ if name.is_empty() {
+ return Ok(());
+ }
+ let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
+ components.sort();
+ components.dedup_by_key(|c| c.0);
+ components.truncate(MAX_REPORTED);
+ let Some(u) = self.usage_of(file)? else { return Ok(()) };
+ match u.list.iter_mut().find(|f| f.usage.file == name) {
+ Some(f) if f.usage.components == components => return Ok(()),
+ Some(f) => {
+ f.usage.components = components;
+ f.updated = now();
+ }
+ None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
+ }
+ if u.list.len() > MAX_USERS {
+ u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
+ u.list.truncate(MAX_USERS);
+ }
+ u.dirty = true;
+ if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
+ self.write_usage(false);
+ }
+ Ok(())
+ }
+
+ /// Write the usage reports that changed (`all`: whenever they were last written).
+ pub(super) fn write_usage(&mut self, all: bool) {
+ let t = now();
+ let root = self.root.join(FILES);
+ for (file, u) in &mut self.usage {
+ if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
+ if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
+ log::warn!("file {file}: library usage couldn't be kept: {e}");
+ }
+ (u.dirty, u.written) = (false, t);
+ }
+ }
+ }
+}
+
+/// The name of the file holding version `version` of a file's team library.
+pub fn library_name(version: u64) -> String {
+ format!("library-{version}.bin")
+}
+
+/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
+/// version published before each version had one.
+fn library_file(dir: &Path, version: u64) -> PathBuf {
+ let path = dir.join(library_name(version));
+ if path.exists() { path } else { dir.join(LIBRARY) }
+}
diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs
new file mode 100644
index 00000000..ad30f458
--- /dev/null
+++ b/apps/figcraft-server/src/store/links.rs
@@ -0,0 +1,154 @@
+use super::*;
+
+impl Store {
+ /// The files kept.
+ pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec {
+ self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
+ }
+
+ /// A file's links.
+ pub fn links(&mut self, file: &str) -> Result, String> {
+ Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
+ }
+
+ /// A new link to a file.
+ pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result {
+ let max = self.cfg.max_links;
+ let path = self.dir(file).join(LINKS);
+ let entry = self.entry(file)?;
+ if entry.links.len() >= max {
+ return Err(format!("a file has at most {max} links: revoke one first"));
+ }
+ let (link, token) = new_link(access, &clean_name(label), now())?;
+ entry.links.push(link.clone());
+ if let Err(e) = write_json(&path, &entry.links) {
+ entry.links.pop();
+ return Err(format!("couldn't keep the link: {e}"));
+ }
+ Ok(NewLink { link: link.info(file), token })
+ }
+
+ /// The link of `entry` whose token `token` is (every link is compared, in constant time, so
+ /// timing doesn't tell which one matched).
+ pub(super) fn link_of(entry: &Entry, token: &str) -> Option {
+ let presented = hash(token);
+ let mut found = None;
+ for (i, l) in entry.links.iter().enumerate() {
+ if same_secret(&presented, &l.hash) {
+ found = Some(i);
+ }
+ }
+ found
+ }
+
+ /// These join codes no longer open anything: typing one says so.
+ fn forget_codes(&mut self, codes: impl IntoIterator- ) {
+ self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
+ let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
+ self.gone_codes.drain(..extra);
+ }
+
+ /// Where the link with the join code `key` is: its file, its place among the file's links
+ /// and its token (every code is compared, so timing doesn't tell which one matched).
+ fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
+ let mut found = None;
+ for (id, e) in &self.files {
+ for (i, l) in e.links.iter().enumerate() {
+ if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
+ found = Some((id.clone(), i, token));
+ }
+ }
+ }
+ found
+ }
+
+ /// A new join code for the link of `file` whose token is `token` (it takes the place of the
+ /// link's earlier code): the code, shown this once.
+ pub fn set_code(&mut self, file: &str, token: &str) -> Result
{
+ let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
+ // 45 random bits: one already in use is all but impossible, and never handed out twice.
+ let (code, key) = loop {
+ let code = codes::new_code()?;
+ let key = codes::Key::new(&code);
+ if self.find_code(&key).is_none() {
+ break (code, key);
+ }
+ };
+ let sealed = key.seal(token)?;
+ let path = self.dir(file).join(LINKS);
+ let entry = self.entry(file)?;
+ let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
+ let before = link.code.replace(sealed);
+ let id = link.id.clone();
+ if let Err(e) = write_json(&path, &entry.links) {
+ if let Some(l) = entry.links.get_mut(at) {
+ l.code = before;
+ }
+ return Err(format!("couldn't keep the join code: {e}"));
+ }
+ self.forget_codes(before);
+ log::info!("event=code file={file} link={id}");
+ Ok(code)
+ }
+
+ /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
+ /// the address (see `abuse`), and nothing is looked up for an address that is locked out.
+ pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
+ let now = Instant::now();
+ if let Some(left) = self.abuse.code_locked(ip, now) {
+ return Err(format!(
+ "Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
+ left.as_secs() / 60 + 1
+ ));
+ }
+ if !self.abuse.allow(ip, Action::Code, now) {
+ return Err("Too many codes tried from your address: wait a minute and try again".into());
+ }
+ if !self.abuse.codes_open(now) {
+ return Err(CODES_BUSY.into());
+ }
+ // Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
+ let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
+ let found = key.as_ref().and_then(|k| self.find_code(k));
+ self.abuse.code_tried(ip, found.is_some(), now);
+ match found {
+ Some((file, _, token)) => {
+ log::info!("event=code_used file={file} ip={ip}");
+ Ok((file, token))
+ }
+ None => {
+ let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
+ Err(if gone { CODE_GONE } else { NO_CODE }.into())
+ }
+ }
+ }
+
+ /// Revoke a link.
+ pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
+ let path = self.dir(file).join(LINKS);
+ let entry = self.entry(file)?;
+ let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
+ let gone = entry.links.remove(at);
+ if let Err(e) = write_json(&path, &entry.links) {
+ entry.links.insert(at, gone);
+ return Err(format!("couldn't revoke the link: {e}"));
+ }
+ log::info!("revoked link {link} of file {file}");
+ self.forget_codes(gone.code);
+ Ok(())
+ }
+
+ /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
+ /// longer served either way).
+ pub fn remove(&mut self, file: &str) -> Result<(), String> {
+ // Closed first: an open log would keep the folder from being deleted on Windows.
+ let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
+ self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
+ drop(entry);
+ let dir = self.dir(file);
+ fs::remove_dir_all(&dir)
+ .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
+ log::info!("removed file {file}");
+ Ok(())
+ }
+}
diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs
index d8c5b915..93a872ee 100644
--- a/apps/figcraft-server/src/store_tests.rs
+++ b/apps/figcraft-server/src/store_tests.rs
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
+#[test]
+fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
+ let dir = TempDir::new("read-only-reconnects");
+ let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
+ let (id, original) = {
+ let mut store = Store::open(dir.path(), c.clone()).unwrap();
+ let (id, _, _) = publish(&mut store, "Read only");
+ let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
+ (id, bytes)
+ };
+ let mut last_client = 0;
+ for _ in 0..6 {
+ let mut store = Store::open(dir.path(), c.clone()).unwrap();
+ let mut server = store.open(&id).unwrap();
+ let client = join(&mut server, Access::View);
+ assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
+ last_client = client.id();
+ store.changed(&id, &mut server);
+ // Idle must sync even if no periodic flush ran before the connection closed.
+ assert!(store.idle(&id, &mut server));
+ assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
+ assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
+ let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
+ assert!(!log.entries.is_empty());
+ assert_eq!(server.seq(), 0);
+ }
+}
+
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -252,6 +280,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
+#[test]
+fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
+ let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
+ let body = figcraft_collab::message::encode(&state).unwrap();
+ let mut legacy = b"FCSN\x01".to_vec();
+ legacy.extend_from_slice(&37u64.to_le_bytes());
+ legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
+ legacy.extend_from_slice(&body);
+ let encoded = disk::encode_snapshot(37, &state).unwrap();
+ assert_eq!(encoded, legacy);
+ assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
+}
+
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml
index ffd44f3b..aa0fdffb 100644
--- a/apps/figcraft-web/Cargo.toml
+++ b/apps/figcraft-web/Cargo.toml
@@ -24,6 +24,8 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
+base64 = { workspace = true }
+serde_json = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html
index 1a9969d5..f610aa81 100644
--- a/apps/figcraft-web/index.html
+++ b/apps/figcraft-web/index.html
@@ -2,12 +2,12 @@
-
+
FigCraft
\n", out.markup, out.tokens.root_rule(), out.styles)
-}
-
-/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
-/// markup (text as Rust string literals); the last two with component parameters and instances
-/// as calls. With its stylesheet.
-pub(crate) struct Markup<'c, 'a> {
- ctx: &'c Ctx<'a>,
- /// Html, React or Leptos.
- lang: CodeLang,
- used: Vec,
- pub markup: String,
- pub styles: String,
- pub tokens: Tokens,
- /// The root's class (default: from its name) and extra JSX attributes.
- pub root_class: Option,
- pub root_attrs: String,
- /// Each layer's path in the tree (names with sibling counts), class and declarations, so
- /// variants can be compared layer by layer.
- pub rules: Vec<(String, String, String)>,
-}
-
-impl<'c, 'a> Markup<'c, 'a> {
- pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
- Markup {
- ctx,
- lang,
- used: Vec::new(),
- markup: String::new(),
- styles: String::new(),
- tokens: Tokens::default(),
- root_class: None,
- root_attrs: String::new(),
- rules: Vec::new(),
- }
- }
-
- /// JSX indents by two spaces, HTML and Leptos by four.
- fn pad(&self, depth: usize) -> String {
- if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
- }
-
- /// Component code (React or Leptos): parameters and instances as calls.
- fn code(&self) -> bool {
- self.lang != CodeLang::Html
- }
-
- /// A parameter's name in the generated code.
- fn ident(&self, p: &Param) -> String {
- if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
- }
-
- /// A layer, shown only while its boolean parameter is on (component code).
- pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
- let Some(n) = self.ctx.doc.node(id) else { return };
- if self.code()
- && id != self.ctx.root
- && let Some(p) = self.ctx.param(n, "visible")
- {
- let (pad, p) = (self.pad(depth), self.ident(p));
- let (open, close) =
- if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
- self.markup.push_str(&format!("{pad}{open}\n"));
- self.element(n, parent, depth + 1, path);
- self.markup.push_str(&format!("{pad}{close}\n"));
- return;
- }
- self.element(n, parent, depth, path);
- }
-
- fn rule(&mut self, path: &str, class: &str, decls: String) {
- self.styles.push_str(&format!(".{class} {{\n"));
- for line in decls.lines() {
- self.styles.push_str(&format!(" {line}\n"));
- }
- self.styles.push_str("}\n");
- self.rules.push((path.to_string(), class.to_string(), decls));
- }
-
- fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) {
- let doc = self.ctx.doc;
- let root = n.id == self.ctx.root;
- let class = match self.root_class.take().filter(|_| root) {
- Some(c) => {
- self.used.push(c.clone());
- c
- }
- None => class_name(&n.name, &mut self.used),
- };
- let pad = self.pad(depth);
- let attr = if self.lang == CodeLang::React { "className" } else { "class" };
- // How it sits in its parent: in the flex flow, or placed at its position.
- let mut decls = String::new();
- let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
- if root {
- decls.push_str("position: relative;\n");
- } else if in_flow {
- let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical);
- let (main, cross) =
- if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) };
- if main == Sizing::Fill {
- decls.push_str("flex: 1 1 0;\n");
- }
- if cross == Sizing::Fill {
- decls.push_str("align-self: stretch;\n");
- }
- } else {
- let c = n.transform.as_coeffs();
- decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5])));
- let rot = figcraft_geom::rotation_degrees(n.transform);
- if rot.abs() > 0.01 {
- decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
- }
- }
- // Nested instances are calls (component code), in a wrapper that places them when needed.
- if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
- let at = depth + usize::from(!decls.is_empty());
- let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
- let inner = match self.ctx.param(n, "mainComponent") {
- Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
- None => self.ctx.call(n, self.lang, at),
- };
- if let Some(inner) = inner {
- if decls.is_empty() {
- self.markup.push_str(&inner);
- } else {
- self.markup.push_str(&format!("{pad}\n{inner}{pad}
\n"));
- self.rule(path, &class, decls);
- }
- return;
- }
- }
- let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
- let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
- let mut own = css_decls(doc, n, &mut self.tokens, hug);
- if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
- own.push_str("position: relative;\n");
- }
- decls.push_str(&own);
- self.rule(path, &class, decls);
- let attrs = if root { self.root_attrs.clone() } else { String::new() };
- let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
- match &n.kind {
- NodeKind::Text(t) => {
- let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
- (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
- (Some(p), _) => format!("{{{}}}", p.ident),
- (None, CodeLang::React) => jsx_text(&t.characters),
- // `view!` takes text as string literals and void elements self-closed.
- (None, CodeLang::Leptos) => {
- t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join(" ")
- }
- (None, _) => html_escape(&t.characters),
- };
- self.markup.push_str(&format!("{}{body}\n", open("p")));
- }
- _ => {
- let kids = self.ctx.kids(n);
- let slot = self.ctx.param(n, "slot").filter(|_| self.code());
- let leptos = self.lang == CodeLang::Leptos;
- let open = open("div");
- if let Some(p) = slot.filter(|_| kids.is_empty()) {
- let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
- self.markup.push_str(&format!("{open}{{{content}}}\n"));
- } else if kids.is_empty() || self.ctx.too_deep(depth) {
- self.markup.push_str(&format!("{open}\n"));
- } else {
- self.markup.push_str(&format!("{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
- let inner = match slot {
- Some(p) if leptos => {
- self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
- depth + 2
- }
- Some(p) => {
- self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
- depth + 3
- }
- None => depth + 1,
- };
- let mut seen: Vec<&str> = Vec::new();
- // Bottom layer first: later elements paint on top, like the layer order.
- for k in kids {
- let Some(kn) = doc.node(k) else { continue };
- let count = seen.iter().filter(|s| **s == kn.name).count();
- seen.push(&kn.name);
- self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
- }
- match slot {
- Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
- Some(_) => self.markup.push_str(&format!("{p2}>\n{p1})}}\n")),
- None => {}
- }
- self.markup.push_str(&format!("{pad}\n"));
- }
- }
- }
- }
-}
-
-// ------------------------------------------------------------------ SwiftUI
-
-fn swift_color(c: Color) -> String {
- format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a)))
-}
-
-fn swift_weight(w: u16) -> &'static str {
- match w {
- 0..=150 => ".thin",
- 151..=250 => ".ultraLight",
- 251..=350 => ".light",
- 351..=450 => ".regular",
- 451..=550 => ".medium",
- 551..=650 => ".semibold",
- 651..=750 => ".bold",
- 751..=850 => ".heavy",
- _ => ".black",
- }
-}
-
-/// SwiftUI for a layer and its subtree at `depth`; `extra` modifiers go last (a free child's
-/// offset). A layer a boolean parameter shows sits in an `if`.
-pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) {
- let Some(n) = ctx.doc.node(id) else { return };
- if id != ctx.root
- && let Some(p) = ctx.param(n, "visible")
- {
- let pad = indent(depth);
- out.push_str(&format!("{pad}if {} {{\n", p.ident));
- swift_layer(ctx, n, depth + 1, out, extra);
- out.push_str(&format!("{pad}}}\n"));
- return;
- }
- swift_layer(ctx, n, depth, out, extra);
-}
-
-/// A layer name safe for a single-line `//` comment: line breaks become spaces.
-fn one_line(name: &str) -> String {
- name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
-}
-
-fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
- return;
- }
- // Nested instances are calls; an instance swap parameter is a view.
- let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
- Some(p) => Some(format!("{pad}{}\n", p.ident)),
- None => ctx.call(n, CodeLang::SwiftUi, depth),
- };
- if let Some(call) = call {
- out.push_str(&call);
- for m in extra {
- out.push_str(&format!("{pad} {m}\n"));
- }
- return;
- }
- let mut mods: Vec = Vec::new();
- match &n.kind {
- NodeKind::Text(t) => {
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone());
- out.push_str(&format!("{pad}Text({text})\n"));
- mods.push(format!(
- ".font(.custom({}, size: {}).weight({}))",
- literal(&t.style.font_family, false),
- num(t.style.font_size),
- swift_weight(t.style.weight())
- ));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".foregroundColor({})", swift_color(c)));
- }
- }
- NodeKind::Ellipse { .. } => {
- out.push_str(&format!("{pad}Ellipse()\n"));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- NodeKind::Rectangle => {
- out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n))));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".fill({})", swift_color(c)));
- }
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = ctx.kids(n);
- let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)),
- Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)),
- None => "ZStack(alignment: .topLeading) {".to_string(),
- };
- out.push_str(&format!("{pad}{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let slot = ctx.param(n, "slot");
- let inner = match slot {
- Some(p) => {
- out.push_str(&format!("{pad} if let {0} {{\n{pad} {0}\n{pad} }} else {{\n", p.ident));
- depth + 2
- }
- None => depth + 1,
- };
- for c in &children {
- // Free children sit at their offset inside a ZStack.
- let offset: Vec = match doc.node(*c).filter(|_| n.layout.is_none()) {
- Some(cn) => {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- vec![format!(".offset(x: {}, y: {})", num(x), num(y))]
- }
- None => Vec::new(),
- };
- swift(ctx, *c, inner, out, &offset);
- }
- if slot.is_some() {
- out.push_str(&format!("{pad} }}\n"));
- }
- out.push_str(&format!("{pad}}}\n"));
- if let Some(l) = &n.layout {
- let [t, r, b, lft] = l.padding;
- if [t, r, b, lft].iter().any(|v| *v > 0.0) {
- mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r)));
- }
- }
- mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height)));
- if let Some(c) = first_fill(n) {
- mods.push(format!(".background({})", swift_color(c)));
- }
- if radius(n) > 0.0 {
- mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
- }
- }
- _ => {
- out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false)));
- mods.push(".resizable()".into());
- mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
- }
- }
- if let Some(c) = stroke_color(n) {
- mods.push(format!(
- ".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))",
- num(radius(n)),
- swift_color(c),
- num(n.stroke.weight)
- ));
- }
- if let Some(s) = drop_shadow(n) {
- mods.push(format!(
- ".shadow(color: {}, radius: {}, x: {}, y: {})",
- swift_color(s.color),
- num(s.radius / 2.0),
- num(s.offset.0),
- num(s.offset.1)
- ));
- }
- if n.opacity < 1.0 {
- mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
- }
- mods.extend(extra.iter().cloned());
- for m in mods {
- out.push_str(&format!("{pad} {m}\n"));
- }
-}
-
-// ------------------------------------------------------------------ Jetpack Compose
-
-fn kotlin_color(c: Color) -> String {
- let [r, g, b, a] = c.to_rgba8();
- format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
-}
-
-/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
-fn modifier(n: &Node, shape: &str, padding: bool) -> String {
- let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
- if let Some(s) = drop_shadow(n) {
- m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
- }
- if let Some(c) = first_fill(n) {
- m.push_str(&format!(".background({}, {shape})", kotlin_color(c)));
- }
- if let Some(c) = stroke_color(n) {
- m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
- }
- if n.opacity < 1.0 {
- m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
- }
- if padding && let Some(l) = &n.layout {
- let [t, r, b, lft] = l.padding;
- if [t, r, b, lft].iter().any(|v| *v > 0.0) {
- m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
- }
- }
- m
-}
-
-/// Jetpack Compose for a layer and its subtree at `depth`. A layer a boolean parameter shows sits
-/// in an `if`.
-pub(crate) fn compose(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String) {
- let Some(n) = ctx.doc.node(id) else { return };
- if id != ctx.root
- && let Some(p) = ctx.param(n, "visible")
- {
- let pad = indent(depth);
- out.push_str(&format!("{pad}if ({}) {{\n", p.ident));
- compose_layer(ctx, n, depth + 1, out);
- out.push_str(&format!("{pad}}}\n"));
- return;
- }
- compose_layer(ctx, n, depth, out);
-}
-
-fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
- let doc = ctx.doc;
- let pad = indent(depth);
- if ctx.too_deep(depth) {
- out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
- return;
- }
- // Nested instances are calls; an instance swap parameter is a composable.
- let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
- Some(p) => Some(format!("{pad}{}()\n", p.ident)),
- None => ctx.call(n, CodeLang::Compose, depth),
- };
- if let Some(call) = call {
- out.push_str(&call);
- return;
- }
- let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
- match &n.kind {
- NodeKind::Text(t) => {
- let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into());
- let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
- out.push_str(&format!(
- "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
- num(t.style.font_size),
- (t.style.weight().clamp(100, 900) / 100) * 100
- ));
- }
- NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))),
- NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))),
- k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
- let children = ctx.kids(n);
- let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
- Some((LayoutMode::Vertical, l)) => format!(
- "Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{",
- modifier(n, &shape, true),
- num(l.gap)
- ),
- Some((_, l)) => format!(
- "Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
- modifier(n, &shape, true),
- num(l.gap)
- ),
- None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
- };
- out.push_str(&format!("{pad}{open}\n"));
- // A slot shows the instance's content, or else the main's.
- let slot = ctx.param(n, "slot");
- let base = match slot {
- Some(p) => {
- out.push_str(&format!("{pad} if ({0} != null) {{\n{pad} {0}()\n{pad} }} else {{\n", p.ident));
- depth + 1
- }
- None => depth,
- };
- for c in &children {
- if n.layout.is_none()
- && let Some(cn) = doc.node(*c)
- {
- let [_, _, _, _, x, y] = cn.transform.as_coeffs();
- out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(base + 1), num(x), num(y)));
- compose(ctx, *c, base + 2, out);
- out.push_str(&format!("{}}}\n", indent(base + 1)));
- } else {
- compose(ctx, *c, base + 1, out);
- }
- }
- if slot.is_some() {
- out.push_str(&format!("{pad} }}\n"));
- }
- out.push_str(&format!("{pad}}}\n"));
- }
- _ => out.push_str(&format!(
- "{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n",
- n.kind.label(),
- literal(&n.name, true),
- num(n.width),
- num(n.height)
- )),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::Session;
- use crate::params::id_of;
- use serde_json::json;
-
- #[test]
- fn num_keeps_huge_whole_numbers() {
- assert_eq!(num(1e20), "100000000000000000000");
- assert_eq!(num(-1e20), "-100000000000000000000");
- assert_eq!(num(1e18), "1000000000000000000");
- assert_eq!(num(-0.0), "0");
- }
-
- fn card(s: &mut Session) -> NodeId {
- let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap();
- s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap();
- s.execute("text.create", &json!({"text": "Say \"hi\" $5\nnow", "x": 0, "y": 0, "parent": f})).unwrap();
- s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
- s.execute("layout.add", &json!({"ids": [f]})).unwrap();
- f
- }
-
- #[test]
- fn swiftui_and_compose_for_an_auto_layout_card() {
- let mut s = Session::new();
- let f = card(&mut s);
- let doc = s.doc().unwrap();
- let sw = code_for(doc, f, CodeLang::SwiftUi);
- assert!(sw.contains("Stack(alignment:"), "{sw}");
- assert!(sw.contains("Text(\"Say \\\"hi\\\" $5\\nnow\")"), "escaped: {sw}");
- assert!(sw.contains("Ellipse()"));
- assert!(sw.contains(".clipShape(RoundedRectangle(cornerRadius: 12))"));
- let kt = code_for(doc, f, CodeLang::Compose);
- assert!(kt.contains("Arrangement.spacedBy("), "{kt}");
- assert!(kt.contains("\\$5"), "Kotlin escapes $: {kt}");
- assert!(kt.contains("CircleShape"));
- assert!(kt.contains("RoundedCornerShape(12.dp)"));
- let css = code_for(doc, f, CodeLang::Css);
- assert!(css.contains("display: flex;") && css.contains("border-radius: 12px;"));
- // Through the command.
- let v = s.execute("inspect.code", &json!({"id": f, "language": "compose"})).unwrap();
- assert_eq!(v["code"].as_str().unwrap(), kt);
- assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err());
- }
-
- #[test]
- fn free_children_get_offsets_and_deep_trees_are_capped() {
- let mut s = Session::new();
- let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
- let root = parent;
- s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
- for _ in 0..20 {
- parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
- }
- let doc = s.doc().unwrap();
- let sw = code_for(doc, root, CodeLang::SwiftUi);
- assert!(sw.contains(".offset(x: 7, y: 9)"), "{sw}");
- assert!(sw.contains("…"), "depth capped");
- let kt = code_for(doc, root, CodeLang::Compose);
- assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
- assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
- }
-
- #[test]
- fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
- let mut s = Session::new();
- let name = "deep\nevil()\r\u{2028}more()";
- let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
- let root = parent;
- for _ in 0..16 {
- parent =
- id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
- .unwrap();
- }
- let doc = s.doc().unwrap();
- for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
- let code = code_for(doc, root, lang);
- assert!(code.contains("// deep evil() more() …\n"), "{code}");
- for line in code.lines() {
- let t = line.trim_start();
- assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
- }
- }
- }
-}
diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs
index 5b759391..d53eeff2 100644
--- a/crates/engine/src/collab_live_tests.rs
+++ b/crates/engine/src/collab_live_tests.rs
@@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() {
let (f, r) = frame_and_rect(room.server.document());
let start = room.server.replica().clone();
let cmds = vec![
- ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
- ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
- ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
+ crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})),
+ crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})),
+ crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
];
room.s(0).batch("Three", &cmds).unwrap();
room.pump();
diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs
index 54ca80c1..22bb53c4 100644
--- a/crates/engine/src/component_codegen_tests.rs
+++ b/crates/engine/src/component_codegen_tests.rs
@@ -83,6 +83,17 @@ fn components_take_their_properties_as_parameters() {
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
}
+ let ark = code(&mut s, c, "arkui");
+ for want in [
+ "@Component\nexport struct Card {",
+ " @Prop showDot: boolean = true",
+ " @Prop title: string = \"Hello\"",
+ " build() {",
+ " if (this.showDot) {",
+ "Text(this.title)",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, c, "leptos");
for want in [
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
@@ -129,13 +140,26 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
// Text that needs escaping is an expression in JSX and escaped per language.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], " \n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
+ // A quoted title becomes a binding whose attribute value is escaped: the expression itself
+ // keeps its JS quotes, and the attribute's own quotes are entities.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
+ let vue = code(&mut s, i, "vue");
+ assert!(vue.starts_with(" \n"), "{vue}");
+ // A plain attribute keeps HTML entities intact: a literal ampersand is written as `&`,
+ // and a literal `&` as `&`.
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
}
#[test]
@@ -185,6 +209,9 @@ fn variant_sets_share_one_definition() {
let kt = code(&mut s, set, "compose");
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
+ let ark = code(&mut s, set, "arkui");
+ assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}");
+ assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}");
let leptos = code(&mut s, set, "leptos");
for want in [
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
@@ -201,6 +228,7 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "react")["usage"], " \n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Button({ state: ButtonState.Hover })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
}
@@ -224,15 +252,29 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
+ let ark = code(&mut s, chip, "arkui");
+ for want in [
+ " @BuilderParam icon: () => void = this.defaultIconBuilder",
+ " @Builder\n defaultIconBuilder() {\n HeartIcon()\n }",
+ " this.icon()",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
+ let vue = code(&mut s, chip, "vue");
+ assert!(vue.contains(" icon?: unknown;"), "{vue}");
+ assert!(vue.contains(" icon: HeartIcon,"), "the swap defaults to its component: {vue}");
+ assert!(vue.contains(" "), "a swap is a component, not an element: {vue}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], " } />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
+ assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " }} />\n");
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
}
#[test]
@@ -247,6 +289,14 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
+ let ark = code(&mut s, panel, "arkui");
+ for want in [
+ " @BuilderParam content: () => void = this.defaultContentBuilder",
+ " @Builder\n defaultContentBuilder() {\n",
+ " this.content()",
+ ] {
+ assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
+ }
let leptos = code(&mut s, panel, "leptos");
assert!(leptos.contains(" #[prop(optional, into)] content: Option,"), "{leptos}");
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
@@ -260,6 +310,8 @@ fn slots_take_optional_content() {
assert!(usage.starts_with(" "), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with(" ") && usage.ends_with("}} />\n"), "{usage}");
+ let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
+ assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
#[test]
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
deleted file mode 100644
index c85c6b8b..00000000
--- a/crates/engine/src/components.rs
+++ /dev/null
@@ -1,2339 +0,0 @@
-//! 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::*;
-use figcraft_geom::Affine;
-use serde_json::Value;
-
-/// Nesting limit for instances inside instances.
-const MAX_DEPTH: usize = 16;
-/// The override group (and `prop_refs` field) of slots.
-pub const SLOT: &str = "slot";
-/// Every override group a layer can carry.
-pub const OVERRIDE_GROUPS: &[&str] = &[
- "fills",
- "strokes",
- "effects",
- "opacity",
- "visible",
- "name",
- "corners",
- "characters",
- "textStyle",
- "padding",
- "gap",
- "alignment",
- "grids",
- "exports",
- "interactions",
- "size",
- "resizing",
- "position",
- "modes",
- "component",
- "props",
- SLOT,
-];
-
-fn is_instance(doc: &Document, id: NodeId) -> bool {
- doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))
-}
-
-/// A slot (inside an instance) whose children are the instance's own content.
-fn holds_content(n: &Node) -> bool {
- n.overrides.contains(SLOT)
-}
-
-/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content:
-/// content belongs to no instance (it edits freely, and instances placed in it stand alone).
-fn scope(doc: &Document, id: NodeId) -> Vec {
- let mut out = vec![id];
- for a in doc.ancestors(id) {
- if doc.node(a).is_some_and(holds_content) {
- break;
- }
- out.push(a);
- }
- out
-}
-
-/// The component a node belongs to (itself, or the nearest component ancestor).
-pub fn owning_component(doc: &Document, id: NodeId) -> Option {
- let mut chain = vec![id];
- chain.extend(doc.ancestors(id));
- chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
-}
-
-/// The innermost instance containing `id` (`id` itself when it is an instance).
-pub fn owning_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().find(|c| is_instance(doc, *c))
-}
-
-/// The outermost instance containing `id` (`id` itself when it is a top-level instance).
-pub fn root_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c))
-}
-
-/// The innermost instance `id` is a layer of (never `id` itself).
-fn enclosing_instance(doc: &Document, id: NodeId) -> Option {
- scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c))
-}
-
-/// Whether `id` is a layer inside an instance (not the outermost instance itself).
-pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
- enclosing_instance(doc, id).is_some()
-}
-
-/// Mark the `modes` override on layers inside instances whose variable modes differ from their
-/// main layer's, for files from before `modes` was an override group. Copies follow their main
-/// layer's modes, so a layer on other modes chose them itself.
-pub fn mark_layer_modes(doc: &mut Document) {
- let chose: Vec = doc
- .nodes()
- .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
- .map(|n| n.id)
- .filter(|id| inside_instance(doc, *id))
- .collect();
- for id in chose {
- if let Some(n) = doc.node_mut(id) {
- n.overrides.insert("modes".into());
- }
- }
-}
-
-/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
-/// a slot property of its component, or an instance's copy of one (followed through its sources).
-pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
- let mut cur = id;
- for _ in 0..=MAX_DEPTH {
- let n = doc.node(cur)?;
- if !matches!(n.kind, NodeKind::Frame) {
- return None;
- }
- if let Some(name) = n.prop_refs.get(SLOT) {
- let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None };
- return match props.get(name) {
- Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())),
- _ => None,
- };
- }
- cur = n.source?;
- }
- None
-}
-
-/// The name of the slot property frame `id` stands for ([`slot_def`]).
-pub fn slot_name(doc: &Document, id: NodeId) -> Option {
- slot_def(doc, id).map(|(name, _)| name.to_string())
-}
-
-/// Whether `id` is a slot (in a main component, or an instance's copy of one).
-pub fn is_slot(doc: &Document, id: NodeId) -> bool {
- slot_def(doc, id).is_some()
-}
-
-/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's
-/// default content.
-pub fn slot_edited(doc: &Document, id: NodeId) -> bool {
- doc.node(id).is_some_and(holds_content)
-}
-
-/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and
-/// reordered freely.
-pub fn slot_scope(doc: &Document, id: NodeId) -> Option {
- let mut chain = vec![id];
- chain.extend(doc.ancestors(id));
- for c in chain {
- if is_instance(doc, c) {
- return None;
- }
- if is_slot(doc, c) {
- return Some(c);
- }
- }
- None
-}
-
-/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an
-/// instance, outside its slots.
-pub fn structure_locked(doc: &Document, id: NodeId) -> bool {
- owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none()
-}
-
-/// For tree walks that look for where layers can go: whether the children of `id` are locked (in
-/// an instance, outside its slots), given whether `id` itself is (`locked`).
-pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool {
- is_instance(doc, id) || (locked && !is_slot(doc, id))
-}
-
-/// Whether layer `id`'s place (parent and position among its siblings) changed between two
-/// versions only where instance structure allows it.
-pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool {
- let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id)));
- let (was, is) = (place(before), place(after));
- was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p)))
-}
-
-/// The slots of a component or instance (its own: not those of instances nested in it, nor of the
-/// content put into its slots), with their property names, top-most first.
-pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
- let mut seen = 0usize;
- while let Some(c) = stack.pop() {
- seen += 1;
- if seen > 1_000_000 {
- break;
- }
- let Some(n) = doc.node(c) else { continue };
- if let Some(name) = slot_name(doc, c) {
- out.push((c, name));
- }
- if !is_instance(doc, c) && !holds_content(n) {
- stack.extend(n.children.iter().rev().copied());
- }
- }
- out
-}
-
-/// All instances of `component` in the document.
-pub fn instances_of(doc: &Document, component: NodeId) -> Vec {
- doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
-}
-
-/// The component set a variant belongs to.
-pub fn variant_set(doc: &Document, component: NodeId) -> Option {
- doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
-}
-
-/// The layer name a fresh instance of `component` gets: the set's name for variants.
-pub fn default_instance_name(doc: &Document, component: NodeId) -> String {
- let set = variant_set(doc, component).and_then(|s| doc.node(s));
- set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into())
-}
-
-/// Default property values of a component (slots hold content, not values: they have none).
-pub fn default_props(comp: &Node) -> BTreeMap {
- let mut out = BTreeMap::new();
- if let NodeKind::Component { props, .. } = &comp.kind {
- for (k, d) in props {
- if takes_value(d) {
- out.insert(k.clone(), prop_default_value(d));
- }
- }
- }
- out
-}
-
-/// Whether instances store a value for the property (every kind but slots).
-pub fn takes_value(d: &PropDef) -> bool {
- !matches!(d, PropDef::Slot { .. })
-}
-
-/// A property definition's default as a JSON value.
-pub fn prop_default_value(d: &PropDef) -> Value {
- match d {
- PropDef::Boolean { default } => Value::Bool(*default),
- PropDef::Text { default } => Value::String(default.clone()),
- PropDef::InstanceSwap { default, .. } => Value::from(default.0),
- PropDef::Variant { default, .. } => Value::String(default.clone()),
- PropDef::Slot { .. } => Value::Null,
- }
-}
-
-/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
-pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result {
- create_instance_at(doc, component, parent, x, y, None)
-}
-
-/// [`create_instance`] at child `index` of `parent` (None: on top).
-pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result {
- let comp = doc.get(component)?.clone();
- if !matches!(comp.kind, NodeKind::Component { .. }) {
- return Err(DocError::Invalid(format!("{component} is not a component")));
- }
- if doc.is_ancestor(component, parent) {
- return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
- }
- let id = doc.alloc_id();
- let mut root = comp.clone();
- root.id = id;
- root.name = default_instance_name(doc, component);
- root.kind = NodeKind::Instance { component, props: default_props(&comp) };
- root.children.clear();
- root.parent = None;
- root.source = Some(component);
- root.overrides.clear();
- root.transform = Affine::translate((x, y));
- root.prop_refs.clear();
- root.annotations.clear();
- root.code_links.clear();
- // The main's dev resources show on its instances without being copied.
- root.dev_resources.clear();
- // Measurements belong to the layer they were placed on.
- root.measurements.clear();
- // The main's interactions play on its instances in prototypes; the instance's own start empty.
- root.interactions.clear();
- // So do the variable modes the main sets (see `Document::inherited_modes`).
- root.var_modes.clear();
- doc.insert(root, parent, index)?;
- sync_instance(doc, id);
- Ok(id)
-}
-
-/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
-/// has one main per component, so a main component's copy is an instance of it; a variant copied
-/// into its set stays a new variant, and every other layer is deep-copied.
-pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result {
- let n = doc.get(id)?;
- let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
- if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
- return doc.duplicate_into(id, parent, index);
- }
- let transform = n.transform;
- let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
- doc.get_mut(instance)?.transform = transform;
- Ok(instance)
-}
-
-/// Which part of the carried overrides to apply.
-#[derive(Clone, Copy, PartialEq)]
-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`.
-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`.
-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.
-fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap {
- let mut out = BTreeMap::new();
- let mut stack = vec![(root, String::new())];
- while let Some((id, prefix)) = stack.pop() {
- let Some(n) = doc.node(id) else { continue };
- let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
- for c in &n.children {
- let Some(cn) = doc.node(*c) else { continue };
- // A nested instance linked only to its own component so far (an imported one) mirrors no
- // layer yet: it goes by its own name, like the main's layer it will be matched with.
- let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component));
- let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s));
- let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str());
- let k = seen.entry(name).or_insert(0);
- let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") };
- *k += 1;
- let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") };
- out.insert(*c, path.clone());
- if out.len() > 1_000_000 {
- return out;
- }
- if !(src_names && holds_content(cn)) {
- stack.push((*c, path));
- }
- }
- }
- out
-}
-
-/// The binding key prefix of an instance's component property value
-/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a
-/// string variable.
-pub const PROP_BINDING: &str = "componentProperties/";
-
-/// Give `to` the component property bindings of instance `from` (they go with its property
-/// values, which an instance keeps across rebuilds).
-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.
-fn field_group(field: &str) -> &'static str {
- match field {
- "visible" => "visible",
- "characters" => "characters",
- "mainComponent" => "component",
- _ => "",
- }
-}
-
-/// Apply one component-property value to a layer.
-fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
- match field {
- "visible" => {
- if let Some(b) = val.as_bool() {
- n.visible = b;
- }
- }
- "characters" => {
- if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
- && t.characters != sv
- {
- let len = t.char_count();
- t.replace_range(0, len, &sv);
- }
- }
- "mainComponent" => {
- if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val))
- && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }))
- {
- *c = v;
- }
- }
- _ => {}
- }
-}
-
-/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its
-/// slots. An instance whose main component was deleted keeps its layers as they are.
-pub fn sync_instance(doc: &mut Document, inst: NodeId) {
- // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits).
- let old: Vec<(std::sync::Arc, bool)> =
- doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect();
- let kept = slot_content(doc, inst);
- sync_instance_depth(doc, inst, 0, BTreeMap::new());
- restore_slot_content(doc, kept);
- for (a, was_dirty) in old {
- doc.keep_if_unchanged(a, was_dirty);
- }
-}
-
-/// The slots in instance `inst` (nested instances included) holding the instance's own content:
-/// each with its content (the subtrees of its children, pre-order).
-fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
- while let Some(c) = stack.pop() {
- let Some(n) = doc.node(c) else { continue };
- if holds_content(n) {
- let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect();
- out.push((c, nodes));
- } else {
- stack.extend(n.children.iter().copied());
- }
- }
- out
-}
-
-/// Put slot content back after a rebuild, into the slots that still hold the instance's own
-/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints
-/// and geometry overrides ran before, so the content keeps its place.
-fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) {
- for (slot, nodes) in kept {
- let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id));
- if !keep {
- continue;
- }
- let ids: Vec = nodes.iter().map(|n| n.id).collect();
- for n in nodes {
- // Pre-order: every parent is back before its children.
- let _ = insert_linked(doc, n);
- }
- // Never leave links to a layer that couldn't come back.
- for id in ids.into_iter().chain([slot]) {
- let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c)));
- if dangling {
- let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default();
- if let Some(n) = doc.node_mut(id) {
- n.children = live;
- }
- }
- }
- }
-}
-
-/// `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.
-fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) {
- if depth > MAX_DEPTH {
- return;
- }
- let Some(mut root) = doc.node(inst).cloned() else { return };
- let NodeKind::Instance { component, props: root_props } = &root.kind else { return };
- let component = *component;
- let Some(comp) = doc.node(component).cloned() else { return };
- if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) {
- // A missing main, or an instance inside its own main (would recurse forever).
- return;
- }
- let 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());
- }
- }
- // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
- // one layer mirrors it) and, only when those don't match, by name path.
- let mut old_ids = old_layers(doc, inst);
- old_ids.extend(spare.keys().copied());
- old_ids.sort();
- old_ids.dedup();
- let mut old: BTreeMap> = spare;
- let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
- let mut by_src: BTreeMap> = BTreeMap::new();
- for i in &old_ids {
- let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
- if let Some(s) = n.source {
- if let Some(p) = n.parent {
- by_source.entry((p, s)).or_insert(*i);
- }
- by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
- }
- old.insert(*i, n);
- }
- let mut by_path: Option> = None;
- let mut main_paths: Option> = None;
- // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
- // parent keeps its id and overrides).
- let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
- let mut map: BTreeMap = BTreeMap::new();
- map.insert(component, inst);
- let mut used: BTreeSet = BTreeSet::new();
- // Slots holding the instance's own content (new ids), and main layers not copied: the
- // default content of those slots.
- let mut edited: BTreeSet = BTreeSet::new();
- let mut skipped: BTreeSet = BTreeSet::new();
- let mut fresh_ids = Vec::new();
- for s in &src {
- let Some(sn) = doc.node(*s) else { continue };
- if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
- skipped.insert(*s);
- continue;
- }
- let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
- let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
- if found.is_none() {
- let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
- let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false));
- found = mains.get(s).and_then(|p| paths.get(p)).copied();
- }
- let nid = match found {
- Some(o) if used.insert(o) => o,
- _ => {
- fresh_ids.push(*s);
- continue;
- }
- };
- if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
- old.insert(nid, std::sync::Arc::new(m));
- }
- if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
- edited.insert(nid);
- }
- map.insert(*s, nid);
- }
- for c in root.children.clone() {
- let _ = doc.remove(c);
- }
- for s in fresh_ids {
- let nid = doc.alloc_id();
- map.insert(s, nid);
- }
- // Build the copies (pre-order: parents first).
- let mut enclosing: BTreeMap> = BTreeMap::new();
- enclosing.insert(inst, vec![inst]);
- let mut inst_props: BTreeMap> = BTreeMap::new();
- inst_props.insert(inst, eff_props);
- let mut nested = Vec::new();
- let mut new_nodes = Vec::new();
- // Text layers showing other text or style than their main layer (to re-measure).
- let mut remeasure = Vec::new();
- for s in &src {
- let Some(orig) = doc.node(*s) else { continue };
- let Some(&nid) = map.get(s) else { continue };
- let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
- let mut n = orig.clone();
- n.id = nid;
- n.parent = Some(parent);
- n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
- n.source = Some(*s);
- n.overrides.clear();
- n.prop_refs.clear();
- let prev = old.get(&nid);
- // Annotations belong to the layer they were added to, not to its copies.
- n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default();
- n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default();
- if let Some(o) = prev {
- carry_overrides(o, &mut n, Carry::Values);
- keep_laid_out(doc, orig, o, &mut n);
- }
- if !edited.contains(&nid) {
- // No longer a slot in the main: it mirrors the main again.
- n.overrides.remove(SLOT);
- }
- // Component properties: the main layer's bindings use this instance's values; layers of
- // nested instances use the nested instance's values (one level per source hop).
- let enc = enclosing.get(&parent).cloned().unwrap_or_default();
- let mut chain: Vec<&Node> = vec![orig];
- while chain.len() < enc.len() {
- let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
- chain.push(next);
- }
- for (j, level) in chain.iter().enumerate().rev() {
- let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
- for (field, prop) in &level.prop_refs {
- let g = field_group(field);
- if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
- continue;
- }
- if let Some(val) = props.get(prop) {
- apply_prop(doc, &mut n, field, val);
- }
- }
- }
- 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);
- }
- }
- // Root: follows the main except its own placement, name, size and overrides.
- let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
- let mut fresh = comp.clone();
- fresh.id = inst;
- fresh.parent = root.parent;
- fresh.children = order;
- fresh.transform = root.transform;
- fresh.kind = root.kind.clone();
- carry_prop_bindings(&root, &mut fresh);
- // A nested instance keeps mirroring its layer of the outer main.
- let nested_copy = inside_instance(doc, inst);
- fresh.source = if nested_copy { root.source } else { Some(component) };
- fresh.constraints = root.constraints;
- fresh.child_layout = root.child_layout.clone();
- fresh.interactions = root.interactions.clone();
- fresh.exports = root.exports.clone();
- fresh.prop_refs = root.prop_refs.clone();
- fresh.plugin_data = root.plugin_data.clone();
- fresh.annotations = root.annotations.clone();
- // Code links belong to the main component; instances read them from it.
- fresh.code_links.clear();
- // An instance's own dev resources (its main's are listed with them, not copied).
- fresh.dev_resources = root.dev_resources.clone();
- fresh.measurements = root.measurements.clone();
- fresh.locked = root.locked;
- fresh.visible = root.visible;
- fresh.var_modes = root.var_modes.clone();
- fresh.is_mask = root.is_mask;
- fresh.exposed = root.exposed;
- fresh.guides = root.guides.clone();
- fresh.dev_status = root.dev_status;
- fresh.dev_changed = root.dev_changed;
- fresh.dev_note = root.dev_note.clone();
- fresh.constrain_proportions = root.constrain_proportions;
- // How the instance combines in a boolean group it sits in is its own.
- fresh.bool_op = root.bool_op;
- // A swapped nested instance keeps the name its layer was given in the outer main.
- let renamed = nested_copy
- && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {
- NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c),
- _ => false,
- });
- fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
- // Switched to another variant: its own overrides stay where both variants agreed.
- if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
- let differs = diff_groups(prev, &comp);
- root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
- follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
- }
- carry_overrides(&root, &mut fresh, Carry::Root);
- if let Some(r) = doc.node_mut(inst) {
- *r = fresh;
- }
- // A resized instance: children follow their constraints (auto layout re-flows later).
- let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
- if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
- figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
- remeasure = map.values().copied().collect();
- }
- // Geometry overrides of children win over constraints.
- for s in &src {
- let Some(nid) = map.get(s).copied() else { continue };
- let Some(o) = old.get(&nid) else { continue };
- // A frame that kept the size its own resizing gave it: its layers (copied at their main
- // layers' places) follow their constraints from its main layer's size.
- if o.overrides.contains("resizing")
- && !o.overrides.contains("size")
- && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
- && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
- {
- figcraft_layout::apply_constraints(doc, nid, mw, mh);
- }
- if !o.overrides.contains("size") && !o.overrides.contains("position") {
- continue;
- }
- remeasure.push(nid);
- let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
- if o.overrides.contains("size") {
- // Through set_size so vector networks scale with the box.
- figcraft_layout::set_size(doc, nid, o.width, o.height);
- if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
- figcraft_layout::apply_constraints(doc, nid, ow, oh);
- }
- }
- if let Some(n) = doc.node_mut(nid) {
- carry_overrides(o, n, Carry::Geometry);
- }
- }
- // Text whose characters or style differ from the main re-measures.
- for nid in remeasure {
- if let Some(n) = doc.node(nid)
- && let Some((w, h)) = figcraft_text::auto_size(n)
- && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
- {
- figcraft_layout::set_size(doc, nid, w, h);
- }
- }
- // Nested instances showing another main than the main's copy (swapped): rebuild from it,
- // with the old layers below them this rebuild didn't reuse.
- let unused: BTreeMap> =
- old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
- for n in nested {
- let mut below: BTreeMap> = unused
- .iter()
- .filter(|(_, x)| {
- let mut cur = x.parent;
- for _ in 0..=MAX_DEPTH * 4 {
- match cur {
- Some(p) if p == n => return true,
- Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
- None => return false,
- }
- }
- false
- })
- .map(|(i, x)| (*i, x.clone()))
- .collect();
- take_placing_overrides(doc, n, &old, &mut below);
- sync_instance_depth(doc, n, depth + 1, below);
- }
-}
-
-/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
-/// copies just made of the outer main's, and `below`) takes the overrides held along the source
-/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
-/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
-/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
-/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
-fn take_placing_overrides(
- doc: &mut Document,
- n: NodeId,
- before: &BTreeMap>,
- below: &mut BTreeMap>,
-) {
- let placing = placing_mains(doc, n);
- for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
- let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
- if let Some(layer) = doc.node_mut(id) {
- take(layer, &held);
- }
- }
- for layer in below.values_mut() {
- let held = held_along(doc, layer, &placing);
- if !held.is_empty() {
- take(std::sync::Arc::make_mut(layer), &held);
- }
- }
-}
-
-/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
-fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet {
- let mut out = BTreeSet::new();
- let mut cur = doc.node(n).and_then(|x| x.source);
- for _ in 0..=MAX_DEPTH {
- let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
- out.extend(owning_component(doc, s.id));
- cur = s.source;
- }
- out
-}
-
-/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
-/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
-fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> {
- let mut out = BTreeMap::new();
- let mut cur = layer.source;
- for _ in 0..=MAX_DEPTH {
- let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
- if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
- break;
- }
- for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
- out.entry(g.clone()).or_insert_with(|| s.clone());
- }
- cur = s.source;
- }
- out
-}
-
-/// Give `layer` the `held` groups it has no override of its own for.
-fn take(layer: &mut Node, held: &BTreeMap>) {
- for (g, from) in held {
- if layer.overrides.insert(g.clone()) {
- copy_group(from, layer, g);
- }
- }
-}
-
-/// Whether main components `a` and `b` are two variants of one set.
-fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
- a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
-}
-
-/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched
-/// variants): `o` without the overrides of properties the two variants' layers differ in (`None`
-/// when it keeps them all). Text layers keep every override.
-fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option {
- let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?;
- if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) {
- return None;
- }
- if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) {
- return None;
- }
- let differs = diff_groups(from, doc.node(s)?);
- let mut m = o.clone();
- m.overrides.retain(|g| !differs.contains(&g.as_str()));
- (m.overrides.len() != o.overrides.len()).then_some(m)
-}
-
-/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of
-/// its slots): what swapping it from the Assets panel keeps.
-pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) {
- for d in old_layers(doc, inst).into_iter().chain([inst]) {
- let Some(n) = doc.node(d) else { continue };
- let text = matches!(n.kind, NodeKind::Text(_));
- let keep = |g: &String| text || g == SLOT || (d == inst && g == "size");
- if !n.overrides.iter().all(keep)
- && let Some(m) = doc.node_mut(d)
- {
- m.overrides.retain(keep);
- }
- }
-}
-
-/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old
-/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually
-/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever
-/// something that sizes or places layers did change.
-fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) {
- if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") {
- return;
- }
- let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
- if flows {
- // The main's rotation and skew, the old place.
- let [a, b, c, d, _, _] = n.transform.as_coeffs();
- let [.., e, f] = o.transform.as_coeffs();
- n.transform = Affine::new([a, b, c, d, e, f]);
- }
- // Only layers whose shape is their box (a vector's points scale with it).
- let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. });
- let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows));
- if derived(n.child_layout.sizing_h) {
- n.width = o.width;
- }
- if derived(n.child_layout.sizing_v) {
- n.height = o.height;
- }
-}
-
-/// The layers of instance `inst` a rebuild matches against (every layer below it but the content
-/// of slots holding its own), ascending.
-fn old_layers(doc: &Document, inst: NodeId) -> Vec {
- let mut out = Vec::new();
- let mut stack = vec![inst];
- while let Some(id) = stack.pop() {
- let Some(n) = doc.node(id) else { continue };
- for c in &n.children {
- out.push(*c);
- if out.len() > 1_000_000 {
- break;
- }
- if doc.node(*c).is_some_and(|cn| !holds_content(cn)) {
- stack.push(*c);
- }
- }
- }
- out.sort();
- out.dedup();
- out
-}
-
-/// Insert a node whose parent/children links are already set (used when rebuilding instances).
-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(())
-}
-
-/// 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).
-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`.
-fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator- + 'a {
- n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g))
-}
-
-/// Property groups that differ between two versions of a node.
-pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
- let mut out = Vec::new();
- if a.fills != b.fills {
- out.push("fills");
- }
- if a.strokes != b.strokes || a.stroke != b.stroke {
- out.push("strokes");
- }
- if a.effects != b.effects {
- out.push("effects");
- }
- if a.opacity != b.opacity || a.blend != b.blend {
- out.push("opacity");
- }
- if a.visible != b.visible {
- out.push("visible");
- }
- if a.name != b.name {
- out.push("name");
- }
- if a.corners != b.corners {
- out.push("corners");
- }
- if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
- out.push("size");
- }
- if resizing(a) != resizing(b) {
- out.push("resizing");
- }
- if a.transform != b.transform {
- out.push("position");
- }
- if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
- if x.characters != y.characters {
- out.push("characters");
- }
- if text_style_differs(x, y) {
- out.push("textStyle");
- }
- }
- if let (Some(x), Some(y)) = (&a.layout, &b.layout) {
- if x.padding != y.padding {
- out.push("padding");
- }
- if x.gap != y.gap || x.cross_gap != y.cross_gap {
- out.push("gap");
- }
- if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align {
- out.push("alignment");
- }
- }
- if a.grids != b.grids || a.styles.grid != b.styles.grid {
- out.push("grids");
- }
- if a.exports != b.exports {
- out.push("exports");
- }
- if a.interactions != b.interactions {
- out.push("interactions");
- }
- if a.var_modes != b.var_modes {
- out.push("modes");
- }
- if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
- if x != y {
- out.push("component");
- }
- if px != py {
- out.push("props");
- }
- }
- out
-}
-
-/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
-fn resizing(n: &Node) -> (Sizing, Sizing, [Option
; 4]) {
- let c = &n.child_layout;
- (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
-}
-
-/// Whether two texts differ in anything but their characters (the `textStyle` group).
-fn text_style_differs(x: &TextData, y: &TextData) -> bool {
- x.style != y.style
- || x.runs != y.runs
- || x.auto_resize != y.auto_resize
- || x.align_h != y.align_h
- || x.align_v != y.align_v
- || x.paragraph_spacing != y.paragraph_spacing
- || x.paragraph_indent != y.paragraph_indent
- || x.list != y.list
- || x.paragraphs != y.paragraphs
- || x.list_spacing != y.list_spacing
- || x.hanging_punctuation != y.hanging_punctuation
- || x.hanging_list != y.hanging_list
- || x.max_lines != y.max_lines
- || x.features != y.features
-}
-
-/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
-/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text.
-fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool {
- for a in doc.ancestors(id) {
- let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue };
- if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 {
- return true;
- }
- }
- let Some(n) = doc.node(id) else { return true };
- if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute {
- return true;
- }
- if group == "size"
- && let NodeKind::Text(t) = &n.kind
- {
- let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6);
- return match t.auto_resize {
- TextAutoResize::WidthAndHeight => true,
- TextAutoResize::Height => !wchanged,
- _ => false,
- };
- }
- false
-}
-
-/// The components sharing the properties of component `c`: the variants of its set, or `c` alone.
-pub fn property_owners(doc: &Document, c: NodeId) -> Vec {
- match variant_set(doc, c).and_then(|s| doc.node(s)) {
- Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(),
- None => vec![c],
- }
-}
-
-/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding
-/// their layers bound to it: on a main component, a boolean property's value is the visibility of
-/// its layers. Returns the components and layers it changed.
-pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec {
- let mut out = Vec::new();
- for &c in comps {
- let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue };
- let Some(PropDef::Boolean { default }) = props.get(name) else { continue };
- if *default != value
- && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind)
- {
- props.insert(name.to_string(), PropDef::Boolean { default: value });
- out.push(c);
- }
- // The main's own layers (not those of instances nested in it, which follow their mains).
- let layers: Vec = doc
- .subtree(c)
- .into_iter()
- .filter(|d| *d != c && !inside_instance(doc, *d))
- .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name)))
- .collect();
- for d in layers {
- if let Some(n) = doc.node_mut(d) {
- n.visible = value;
- out.push(d);
- }
- }
- }
- out
-}
-
-/// A main component's layer bound to a boolean property was shown or hidden: that is the
-/// property's new default, which its other bound layers (in every variant) take too. Returns the
-/// components and layers that changed as a result.
-fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec {
- let mut out = Vec::new();
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- if n.visible == b.visible || inside_instance(doc, id) {
- continue;
- }
- let Some(name) = n.prop_refs.get("visible").cloned() else { continue };
- let visible = n.visible;
- let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue };
- out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible));
- }
- out
-}
-
-/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
-/// whose properties or main were switched, or whose overrides were reset.
-pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) {
- let mut changed = changed.to_vec();
- for id in follow_boolean_defaults(doc, before, &changed) {
- if !changed.contains(&id) {
- changed.push(id);
- }
- }
- let changed = changed.as_slice();
- // 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);
- }
- }
- let mut resync: BTreeSet = BTreeSet::new();
- // 1. Overrides for layers edited inside instances.
- for &id in changed {
- let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
- let Some(inst) = owning_instance(doc, id) else { continue };
- let top_root = inst == id && !inside_instance(doc, id);
- if top_root
- && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
- && (c1 != c2 || p1 != p2)
- {
- // Switching the main or a property value of an instance isn't an override.
- resync.insert(id);
- }
- let raw = diff_groups(b, n);
- let mut groups = raw.clone();
- // An instance's own place, resizing, export settings, interactions and variable modes are
- // its own, not overrides.
- if top_root {
- groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
- }
- if groups.iter().any(|g| matches!(*g, "size" | "position")) {
- groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
- }
- // A value equal to the main's isn't an override (setting it back clears the override).
- let mut cleared = Vec::new();
- if let Some(src) = n.source.and_then(|s| doc.node(s)) {
- let mut differs = diff_groups(src, n);
- if inst == id {
- differs.retain(|g| *g != "name");
- if n.name != default_instance_name(doc, src.id) {
- differs.push("name");
- }
- }
- // A value from the same variable as the main's (resolved in this layer's modes) isn't an
- // override either.
- let from_variables = variable_groups(n, src);
- groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
- // Binding another variable (or none) where the main binds one is an override.
- for (k, _) in b.bound.iter().chain(n.bound.iter()) {
- let Some(g) = binding_group(k) else { continue };
- let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
- if rebound && !groups.contains(&g) {
- groups.push(g);
- }
- }
- cleared =
- raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
- if top_root {
- cleared.retain(|g| !matches!(*g, "component" | "props"));
- }
- }
- let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
- let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
- if (!groups.is_empty() || !cleared.is_empty())
- && let Some(m) = doc.node_mut(id)
- {
- for g in groups {
- m.overrides.insert(g.to_string());
- }
- for g in cleared {
- m.overrides.remove(g);
- }
- }
- if (lost || nested_switch)
- && let Some(r) = root_instance(doc, id)
- {
- resync.insert(r);
- }
- }
- // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
- // visibility, …) are carried to the copies directly; anything else rebuilds the instances.
- let mut comps: BTreeSet = BTreeSet::new();
- let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new();
- // Main layers that only gained new layers or lost some: their copies take the same change.
- let mut restructured: Vec = Vec::new();
- let mut covered: BTreeSet = BTreeSet::new();
- for &id in changed {
- if let (Some(b), Some(n)) = (before.node(id), doc.node(id))
- && b.children != n.children
- && owning_instance(doc, id).is_none()
- && owning_component(doc, id).is_some()
- && let Some(layers) = restructurable(doc, before, b, n)
- {
- restructured.push(id);
- covered.extend(layers);
- }
- }
- for &id in changed {
- if covered.contains(&id) || restructured.contains(&id) {
- continue;
- }
- let Some(c) = owning_component(doc, id) else { continue };
- let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else {
- comps.insert(c);
- continue;
- };
- if b == n {
- continue;
- }
- match patchable(doc, b, n) {
- Some(groups) => patches.push((id, groups)),
- None => {
- comps.insert(c);
- }
- }
- }
- for &id in touched {
- if !doc.contains(id)
- && !covered.contains(&id)
- && let Some(c) = owning_component(before, id)
- && doc.contains(c)
- {
- comps.insert(c);
- }
- }
- for r in resync {
- if doc.contains(r) {
- sync_instance(doc, r);
- if let Some(c) = owning_component(doc, r) {
- comps.insert(c);
- }
- }
- }
- // A copy the patch can't follow exactly rebuilds instead (as does any other change).
- let patched = comps.is_empty()
- && !full_rebuild()
- && (restructured.is_empty() || patch_structure(doc, before, &restructured))
- && (patches.is_empty() || patch_copies(doc, &patches));
- #[cfg(test)]
- if patched && (!restructured.is_empty() || !patches.is_empty()) {
- PATCHED.with(|c| c.set(c.get() + 1));
- }
- if !patched {
- comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id)));
- }
- // 3. Instances of changed components follow; an instance inside another component makes that
- // one change too.
- rebuild_instances(doc, &comps);
-}
-
-#[cfg(test)]
-thread_local! {
- /// Tests: rebuild instances even for edits that could be patched (to compare both ways).
- pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) };
- /// Tests: edits carried to the copies without rebuilding instances.
- pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) };
-}
-
-#[cfg(test)]
-fn full_rebuild() -> bool {
- FULL_REBUILD.with(std::cell::Cell::get)
-}
-
-#[cfg(not(test))]
-fn full_rebuild() -> bool {
- false
-}
-
-/// The property groups of a main-component layer edited from `b` to `n` when the edit changed
-/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without
-/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed.
-fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> {
- const VALUES: [&str; 17] = [
- "fills",
- "strokes",
- "effects",
- "opacity",
- "visible",
- "name",
- "corners",
- "characters",
- "textStyle",
- "padding",
- "gap",
- "alignment",
- "grids",
- "exports",
- "interactions",
- "size",
- "position",
- ];
- if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() {
- return None;
- }
- let groups = diff_groups(b, n);
- if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) {
- return None;
- }
- // Exactly those groups (and the layer's own overrides) changed.
- let mut probe = n.clone();
- for g in &groups {
- copy_group(b, &mut probe, g);
- }
- probe.overrides = b.overrides.clone();
- if probe != *b {
- return None;
- }
- // Values a component property sets (on this layer or a main it mirrors) come from instances.
- let mut cur = Some(n);
- for _ in 0..=MAX_DEPTH {
- let Some(x) = cur else { break };
- if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) {
- return None;
- }
- cur = x.source.and_then(|s| doc.node(s));
- }
- Some(groups)
-}
-
-/// Copies by the layer they mirror; nested instances swapped to a main also follow it.
-fn copies_index(doc: &Document) -> BTreeMap> {
- let mut copies: BTreeMap> = BTreeMap::new();
- for n in doc.nodes() {
- let Some(s) = n.source else { continue };
- copies.entry(s).or_default().push(n.id);
- if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind))
- && component != other
- {
- copies.entry(*component).or_default().push(n.id);
- }
- }
- copies
-}
-
-/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances,
-/// components or property bindings in them) or lost layers that were deleted: those layers, which
-/// [`patch_structure`] carries to the copies. `None` for anything else.
-fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> {
- let mut probe = n.clone();
- probe.children = b.children.clone();
- probe.overrides = b.overrides.clone();
- if probe != *b {
- return None;
- }
- let mut layers = Vec::new();
- for c in n.children.iter().filter(|c| !b.children.contains(c)) {
- for d in doc.subtree(*c) {
- let x = doc.node(d)?;
- let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
- if before.contains(d) || !plain || !x.prop_refs.is_empty() {
- return None;
- }
- layers.push(d);
- }
- }
- for c in b.children.iter().filter(|c| !n.children.contains(c)) {
- if doc.contains(*c) {
- return None;
- }
- layers.extend(before.subtree(*c));
- }
- Some(layers)
-}
-
-/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy,
-/// through copies of copies (slots holding an instance's own content keep it). Returns false
-/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly.
-fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool {
- let copies = copies_index(doc);
- let mut work: Vec = seeds.to_vec();
- let mut steps = 0usize;
- while let Some(p) = work.pop() {
- steps += 1;
- if steps > 10_000_000 {
- return false;
- }
- let Some(pn) = doc.node(p).cloned() else { continue };
- for c in copies.get(&p).cloned().unwrap_or_default() {
- let Some(cn) = doc.node(c).cloned() else { continue };
- if holds_content(&cn) {
- continue;
- }
- if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
- && a != b
- {
- continue;
- }
- // The copy's layers by the layer of `p` they mirror.
- let mut mirrors: BTreeMap = BTreeMap::new();
- for k in &cn.children {
- let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false };
- if mirrors.insert(src, *k).is_some() {
- return false;
- }
- }
- let mut order = Vec::with_capacity(pn.children.len());
- for pc in &pn.children {
- if let Some(k) = mirrors.remove(pc) {
- order.push(k);
- continue;
- }
- // A layer the copy lacks must be new in this edit, placed as in the main.
- let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute);
- if before.contains(*pc) || !child_follows_main(doc, c, absolute) {
- return false;
- }
- match copy_layers(doc, *pc, c) {
- Some(k) => order.push(k),
- None => return false,
- }
- }
- // Copies of layers the main lost.
- for (src, k) in mirrors {
- if doc.contains(src) || doc.remove(k).is_err() {
- return false;
- }
- }
- if doc.node(c).is_some_and(|x| x.children != order)
- && let Some(m) = doc.node_mut(c)
- {
- m.children = order;
- }
- work.push(c);
- }
- }
- true
-}
-
-/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's
-/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]).
-fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
- let Some(p) = doc.node(parent) else { return false };
- let constrains = p.layout.is_none() || absolute;
- let resized = sized_own(p)
- || match &p.kind {
- NodeKind::Instance { component, .. } => {
- doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
- }
- _ => false,
- };
- !(constrains && resized) && follows_main_geometry(doc, parent)
-}
-
-/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the
-/// new copy (the caller places it among `parent`'s layers).
-fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option {
- let sub = doc.subtree(src);
- let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect();
- for d in &sub {
- let mut n = doc.node(*d)?.clone();
- n.id = *map.get(d)?;
- n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) };
- n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
- n.source = Some(*d);
- n.overrides.clear();
- n.prop_refs.clear();
- n.annotations.clear();
- n.measurements.clear();
- insert_linked(doc, n).ok()?;
- }
- map.get(&src).copied()
-}
-
-/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through
-/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial
-/// patch for the caller to rebuild over) when a copy can't follow exactly.
-fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool {
- let copies = copies_index(doc);
- let text = |g: &str| matches!(g, "characters" | "textStyle");
- let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec();
- let mut steps = 0usize;
- while let Some((p, groups)) = work.pop() {
- steps += 1;
- if steps > 10_000_000 {
- return false;
- }
- let Some(pn) = doc.node(p).cloned() else { continue };
- for c in copies.get(&p).cloned().unwrap_or_default() {
- let Some(cn) = doc.node(c) else { continue };
- // A swapped nested instance shows its own main, not the layer it stands in for.
- if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
- && a != b
- {
- continue;
- }
- // An instance keeps its own visibility, place, export settings and interactions; its
- // name follows the main's unless renamed; a resized one lays its layers out against
- // the main's size.
- let root = matches!(pn.kind, NodeKind::Component { .. });
- if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) {
- return false;
- }
- let gs: Vec<&'static str> = groups
- .iter()
- .copied()
- .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
- .collect();
- if gs.is_empty() {
- continue;
- }
- let geometry = gs.iter().any(|g| matches!(*g, "size" | "position"));
- if geometry && !follows_main_geometry(doc, c) {
- return false;
- }
- // Text content and style share their runs: a copy overriding one keeps its runs when the
- // other follows the main (as a rebuild carries them).
- let runs = match &cn.kind {
- NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()),
- _ => None,
- };
- if let Some(m) = doc.node_mut(c) {
- for g in &gs {
- copy_group(&pn, m, g);
- }
- if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) {
- t.runs = r;
- }
- }
- if (geometry || gs.iter().any(|g| text(g)))
- && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size)
- && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6)
- {
- figcraft_layout::set_size(doc, c, w, h);
- }
- work.push((c, gs));
- }
- }
- true
-}
-
-/// Whether a copy's size and place are its main layer's: no layer around it inside its instances
-/// applies constraints on the way down to it (a resized instance or frame without auto layout, or
-/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest.
-fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
- let chain = scope(doc, id);
- chain.windows(2).all(|w| {
- let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
- let constrains = a.layout.is_none() || below.child_layout.absolute;
- if sized_own(a) && constrains {
- return false;
- }
- match &a.kind {
- NodeKind::Instance { component, .. } => {
- !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9)
- }
- _ => true,
- }
- })
-}
-
-/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
-/// layer's size, so the layers in it can't take their main layers' places as they are.
-fn sized_own(n: &Node) -> bool {
- n.overrides.contains("size") || n.overrides.contains("resizing")
-}
-
-/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
-/// outwards), each outermost instance once, inner components first.
-fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) {
- if comps.is_empty() {
- return;
- }
- // Outermost instances by the components their layers instantiate.
- let mut roots_of: BTreeMap> = BTreeMap::new();
- for n in doc.nodes() {
- if let NodeKind::Instance { component, .. } = &n.kind {
- roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id));
- }
- }
- // Affected components, with how deep their dependencies go (an instance inside component X of
- // component Y: X follows Y).
- let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect();
- let mut outer: BTreeMap> = BTreeMap::new();
- let mut frontier: Vec = comps.iter().copied().collect();
- let mut rounds = 0;
- while !frontier.is_empty() && rounds < 64 {
- rounds += 1;
- let mut next = Vec::new();
- for c in frontier {
- let lc = level.get(&c).copied().unwrap_or(0);
- for r in roots_of.get(&c).into_iter().flatten() {
- let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r));
- if let Some(x) = o
- && x != c
- {
- let lx = level.entry(x).or_insert(0);
- if *lx < lc + 1 {
- *lx = lc + 1;
- next.push(x);
- }
- }
- }
- }
- frontier = next;
- }
- // Instances inside components by level, then the rest (each once).
- let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect();
- order.sort();
- for (_, r) in order {
- if doc.contains(r) {
- sync_instance(doc, r);
- }
- }
-}
-
-/// 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.
-fn own_slot_content(doc: &mut Document, slot: NodeId) {
- if let Some(n) = doc.node_mut(slot) {
- n.overrides.insert(SLOT.into());
- }
- for nested in unlink_layers(doc, slot, None) {
- rebase_instance(doc, nested);
- }
-}
-
-/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the
-/// content of a slot): plain layers forget the main layer they mirrored and their property
-/// bindings. The instances among them are returned, to re-link to their own mains (with
-/// [`rebase_instance`]) once the caller is done.
-pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec {
- let picked: Vec<(NodeId, bool)> =
- doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect();
- let mut nested = Vec::new();
- for (d, instance) in picked {
- let Some(n) = doc.node_mut(d) else { continue };
- n.prop_refs.clear();
- if instance {
- nested.push(d);
- } else {
- n.source = None;
- n.overrides.clear();
- }
- }
- nested
-}
-
-/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it
-/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept).
-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
-}
-
-/// Bounded source and parent lookups while a rebase reads an unchanged document.
-struct RebaseSources<'a> {
- doc: &'a Document,
- chains: BTreeMap>>,
- parents: BTreeMap<(NodeId, NodeId), Option>,
- main_changes: BTreeMap,
-}
-
-impl<'a> RebaseSources<'a> {
- const PARENTS: usize = 512;
- const CACHE: usize = 8192;
-
- fn new(doc: &'a Document) -> Self {
- Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
- }
-
- fn chain(&mut self, source: NodeId) -> Option> {
- if let Some(chain) = self.chains.get(&source) {
- return chain.clone();
- }
- let read = || {
- let mut chain = Vec::new();
- let mut seen = BTreeSet::new();
- let mut current = Some(source);
- for _ in 0..MAX_DEPTH {
- let Some(id) = current else { return Some(chain) };
- if !seen.insert(id) {
- return None;
- }
- let n = self.doc.node(id)?;
- chain.push(id);
- current = n.source;
- }
- current.is_none().then_some(chain)
- };
- let chain = read();
- if self.chains.len() < Self::CACHE {
- self.chains.insert(source, chain.clone());
- }
- chain
- }
-
- /// Validate the complete parent chain, even if `main` was found before a cycle.
- fn in_main(&mut self, id: NodeId, main: NodeId) -> Option {
- if let Some(found) = self.parents.get(&(id, main)) {
- return *found;
- }
- let read = || {
- let mut current = Some(id);
- let mut seen = BTreeSet::new();
- let mut found = false;
- for _ in 0..Self::PARENTS {
- let Some(id) = current else { return Some(found) };
- if !seen.insert(id) {
- return None;
- }
- found |= id == main;
- current = self.doc.node(id)?.parent;
- }
- current.is_none().then_some(found)
- };
- let found = read();
- if self.parents.len() < Self::CACHE {
- self.parents.insert((id, main), found);
- }
- found
- }
-
- fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option {
- let chain = self.chain(source)?;
- if chain.contains(&target) {
- return None;
- }
- let mut member = None;
- for id in chain {
- if self.in_main(id, main)? && id != main && member.is_none() {
- member = Some(id);
- }
- }
- member
- }
-
- /// Nearest enclosing instances and their current mains. Slots with own content end scope.
- fn owners(&self, id: NodeId) -> Option> {
- let mut owners = Vec::new();
- let mut current = self.doc.node(id)?.parent;
- let mut seen = BTreeSet::from([id]);
- for _ in 0..Self::PARENTS {
- let Some(id) = current else { return Some(owners) };
- if !seen.insert(id) {
- return None;
- }
- let n = self.doc.node(id)?;
- if holds_content(n) {
- return Some(owners);
- }
- if let NodeKind::Instance { component, .. } = &n.kind {
- owners.push((id, *component));
- }
- current = n.parent;
- }
- current.is_none().then_some(owners)
- }
-
- fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
- owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
- }
-
- /// A contextual child belongs to the same placed slot as its enclosing instance.
- fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
- let Some((owner, main)) = owners.first() else { return false };
- if self.member(source, *main, target).is_none() {
- return false;
- }
- if self.in_main(source, *main) == Some(true) {
- return true;
- }
- let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
- matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
- && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
- }
-
- fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option {
- let source = self.doc.node(id)?.source?;
- if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
- || !self.valid(source, id, owners)
- || !self.contextual(source, owners)
- {
- return None;
- }
- Some(source)
- }
-
- fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
- for (index, (id, main)) in owners.iter().enumerate() {
- if *id == stop {
- break;
- }
- let changed = if let Some(changed) = self.main_changes.get(id) {
- *changed
- } else {
- let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
- Some(NodeKind::Instance { component, .. }) => component != main,
- Some(NodeKind::Frame) => true,
- _ => false,
- });
- if self.main_changes.len() < Self::CACHE {
- self.main_changes.insert(*id, changed);
- }
- changed
- };
- if changed {
- return true;
- }
- }
- false
- }
-}
-
-/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
-/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
-/// the look is kept exactly. Values a component property sets aren't overrides.
-pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
- let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
- let mine = path_map(doc, inst, true);
- let theirs: BTreeMap =
- if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() };
- let value_groups = |a: &Node, b: &Node| -> Vec {
- diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
- };
- let mut updates = Vec::new();
- let mut sources = RebaseSources::new(doc);
- let root_owners = sources.owners(inst).unwrap_or_default();
- let placed_source = sources.placed(inst, &root_owners);
- let mut contexts = vec![(inst, component)];
- contexts.extend_from_slice(&root_owners);
- for (id, path) in &mine {
- let Some(n) = doc.node(*id) else { continue };
- let owners = sources.owners(*id).unwrap_or_default();
- let nearest_main = owners.first().map(|(_, main)| *main);
- let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
- let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
- // A swapped or Frame-to-Instance subtree is compared to its current main by the inner
- // rebase. An outer name path must not rebind those children to the old expanded copies.
- if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
- updates.push((*id, valid_source, n.overrides.clone()));
- continue;
- }
- main = main
- .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
- let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
- let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
- if let Some(m) = source.and_then(|source| doc.node(source)) {
- let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
- bound.extend(variable_groups(n, m));
- overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
- if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) {
- overrides.insert("component".into());
- }
- }
- updates.push((*id, source, overrides));
- }
- // A placed nested root compares to its actual slot. A detached root compares to its own main.
- let root_base = placed_source.or_else(|| {
- let chain = sources.chain(component)?;
- (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
- });
- let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
- (Some(m), Some(n)) => {
- let from_variables = variable_groups(n, m);
- let mut groups: Vec = value_groups(m, n)
- .into_iter()
- .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
- .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
- .collect();
- let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
- if named {
- groups.push("name".into());
- }
- if placed_source.is_some()
- && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
- {
- groups.push("component".into());
- }
- groups
- }
- _ => Vec::new(),
- };
- drop(sources);
- for (id, main, overrides) in updates {
- if let Some(n) = doc.node_mut(id) {
- n.source = main;
- n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
- }
- }
- if let Some(n) = doc.node_mut(inst) {
- n.source = root_base;
- // A top-level instance's resizing and modes are its own (a nested one's override its main
- // layer's).
- n.overrides.retain(|g| {
- !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
- });
- n.overrides.extend(root_groups);
- }
-}
-
-#[cfg(test)]
-#[path = "components_rebase_source_tests.rs"]
-mod rebase_source_tests;
-
-/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
-/// the left-most).
-pub fn default_variant(doc: &Document, set: NodeId) -> Option {
- let corner = |c: &NodeId| {
- doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x))
- };
- let kids = &doc.node(set)?.children;
- kids.iter()
- .copied()
- .filter_map(|c| corner(&c).map(|k| (k, c)))
- .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
- .map(|(_, c)| c)
-}
-
-/// Variants of a set sharing one combination of property values (each group two or more, in
-/// layer order): a conflict to resolve by giving each a unique combination.
-pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> {
- let mut by: BTreeMap, Vec> = BTreeMap::new();
- for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
- if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
- by.entry(variant_props(&n.name)).or_default().push(c);
- }
- }
- by.into_values().filter(|v| v.len() > 1).collect()
-}
-
-/// Parse a variant name "Size=Large, State=Hover" into pairs.
-pub fn variant_props(name: &str) -> BTreeMap {
- name.split(',')
- .filter_map(|kv| {
- let (k, v) = kv.split_once('=')?;
- Some((k.trim().to_string(), v.trim().to_string()))
- })
- .collect()
-}
-
-/// Format variant pairs as a layer name, in the given property order (others after, sorted).
-pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String {
- let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect();
- keys.extend(pairs.keys().filter(|k| !order.contains(k)));
- keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ")
-}
-
-/// Variant properties of a set: property → values, in first-seen order.
-pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> {
- let mut out: Vec<(String, Vec)> = Vec::new();
- for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
- let Some(cn) = doc.node(c) else { continue };
- for kv in cn.name.split(',') {
- let Some((k, v)) = kv.split_once('=') else { continue };
- let (k, v) = (k.trim().to_string(), v.trim().to_string());
- match out.iter_mut().find(|(p, _)| *p == k) {
- Some((_, vals)) => {
- if !vals.contains(&v) {
- vals.push(v);
- }
- }
- None => out.push((k, vec![v])),
- }
- }
- }
- out
-}
-
-/// The variant in `set` matching `wanted` best: all given properties equal, or else the most.
-pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option {
- let s = doc.node(set)?;
- let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count());
- if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) {
- return Some(exact);
- }
- s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0))
-}
-
-/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
-/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
-/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
-pub fn mirror_issues(doc: &Document) -> Vec {
- doc.nodes()
- .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id))
- .flat_map(|n| instance_issues(doc, n.id))
- .collect()
-}
-
-/// [`mirror_issues`] for one instance.
-fn instance_issues(doc: &Document, id: NodeId) -> Vec {
- fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) {
- if depth > MAX_DEPTH || holds_content(inst) {
- return;
- }
- if inst.children.len() != main.children.len() {
- out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len()));
- return;
- }
- for (a, b) in inst.children.iter().zip(&main.children) {
- let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue };
- if an.source != Some(bn.id) {
- out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id));
- continue;
- }
- match (&an.kind, &bn.kind) {
- (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => {
- if let Some(m) = doc.node(*x) {
- walk(doc, an, m, depth + 1, out);
- }
- }
- _ => walk(doc, an, bn, depth + 1, out),
- }
- }
- }
- let mut out = Vec::new();
- if let Some(n) = doc.node(id)
- && let NodeKind::Instance { component, .. } = &n.kind
- && let Some(m) = doc.node(*component)
- {
- walk(doc, n, m, 0, &mut out);
- }
- out
-}
-
-#[cfg(test)]
-#[path = "components_tests.rs"]
-pub(crate) mod tests;
-
-/// The nested instance copies inside instance `id` whose main-component counterpart is exposed
-/// (top-most first; an exposed instance's own exposed nested instances follow it).
-pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec {
- let mut out = Vec::new();
- let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
- let mut seen = 0usize;
- while let Some(c) = stack.pop() {
- seen += 1;
- if seen > 100_000 {
- break;
- }
- let Some(n) = doc.node(c) else { continue };
- // A hidden exposed instance hides its properties too.
- let exposed =
- n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
- if exposed {
- out.push(c);
- }
- // Only exposed nested instances pass their own exposed instances up.
- if !matches!(n.kind, NodeKind::Instance { .. }) || exposed {
- stack.extend(n.children.iter().rev().copied());
- }
- }
- out
-}
diff --git a/crates/engine/src/content.rs b/crates/engine/src/content.rs
new file mode 100644
index 00000000..cf32c3eb
--- /dev/null
+++ b/crates/engine/src/content.rs
@@ -0,0 +1,5 @@
+pub mod datafill;
+pub mod synthetic;
+pub mod synthetic_ds;
+pub mod templates;
+pub mod textfit;
diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/content/datafill.rs
similarity index 100%
rename from crates/engine/src/datafill.rs
rename to crates/engine/src/content/datafill.rs
diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/content/synthetic.rs
similarity index 100%
rename from crates/engine/src/synthetic.rs
rename to crates/engine/src/content/synthetic.rs
diff --git a/crates/engine/src/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs
similarity index 100%
rename from crates/engine/src/synthetic_ds.rs
rename to crates/engine/src/content/synthetic_ds.rs
diff --git a/crates/engine/src/templates/mod.rs b/crates/engine/src/content/templates.rs
similarity index 100%
rename from crates/engine/src/templates/mod.rs
rename to crates/engine/src/content/templates.rs
diff --git a/crates/engine/src/templates/dashboard.rs b/crates/engine/src/content/templates/dashboard.rs
similarity index 100%
rename from crates/engine/src/templates/dashboard.rs
rename to crates/engine/src/content/templates/dashboard.rs
diff --git a/crates/engine/src/templates/design_system.rs b/crates/engine/src/content/templates/design_system.rs
similarity index 100%
rename from crates/engine/src/templates/design_system.rs
rename to crates/engine/src/content/templates/design_system.rs
diff --git a/crates/engine/src/templates/glyphs.rs b/crates/engine/src/content/templates/glyphs.rs
similarity index 100%
rename from crates/engine/src/templates/glyphs.rs
rename to crates/engine/src/content/templates/glyphs.rs
diff --git a/crates/engine/src/templates/icon_set.rs b/crates/engine/src/content/templates/icon_set.rs
similarity index 100%
rename from crates/engine/src/templates/icon_set.rs
rename to crates/engine/src/content/templates/icon_set.rs
diff --git a/crates/engine/src/templates/kit.rs b/crates/engine/src/content/templates/kit.rs
similarity index 100%
rename from crates/engine/src/templates/kit.rs
rename to crates/engine/src/content/templates/kit.rs
diff --git a/crates/engine/src/templates/landing.rs b/crates/engine/src/content/templates/landing.rs
similarity index 100%
rename from crates/engine/src/templates/landing.rs
rename to crates/engine/src/content/templates/landing.rs
diff --git a/crates/engine/src/templates/mobile.rs b/crates/engine/src/content/templates/mobile.rs
similarity index 100%
rename from crates/engine/src/templates/mobile.rs
rename to crates/engine/src/content/templates/mobile.rs
diff --git a/crates/engine/src/templates/slides.rs b/crates/engine/src/content/templates/slides.rs
similarity index 100%
rename from crates/engine/src/templates/slides.rs
rename to crates/engine/src/content/templates/slides.rs
diff --git a/crates/engine/src/templates/social.rs b/crates/engine/src/content/templates/social.rs
similarity index 100%
rename from crates/engine/src/templates/social.rs
rename to crates/engine/src/content/templates/social.rs
diff --git a/crates/engine/src/templates/wireframe.rs b/crates/engine/src/content/templates/wireframe.rs
similarity index 100%
rename from crates/engine/src/templates/wireframe.rs
rename to crates/engine/src/content/templates/wireframe.rs
diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/content/textfit.rs
similarity index 98%
rename from crates/engine/src/textfit.rs
rename to crates/engine/src/content/textfit.rs
index 4ab6c50e..f9d549e4 100644
--- a/crates/engine/src/textfit.rs
+++ b/crates/engine/src/content/textfit.rs
@@ -29,6 +29,8 @@ pub fn specs() -> Vec {
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
+ // Every line counts, not only those the layer's own line limit shows.
+ t.max_lines = 0;
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs
index 6ffb33e4..86122c53 100644
--- a/crates/engine/src/cursor_tests.rs
+++ b/crates/engine/src/cursor_tests.rs
@@ -274,13 +274,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() {
let mut s = Session::new();
let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false})));
ex(&mut s, "vector.edit.begin", json!({"id": path}));
- let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0));
+ let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0));
use Cursor::*;
// (vector tool, modifiers, where, cursor, why)
type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str);
let rows: Vec = vec![
("move", NONE, point, Default, "the arrow over a point"),
("move", NONE, segment, Default, "the arrow over a segment"),
+ ("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"),
("move", NONE, empty, Default, "the arrow elsewhere"),
("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"),
("move", ALT, point, Default, "Alt over a point adds nothing"),
diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs
new file mode 100644
index 00000000..765e3be5
--- /dev/null
+++ b/crates/engine/src/derived.rs
@@ -0,0 +1,236 @@
+use super::*;
+
+/// Nodes whose `Arc` differs from `before` (new or edited).
+pub fn changed_nodes(doc: &Document, before: &Document) -> Vec {
+ doc.ids()
+ .filter(|id| match (doc.node_arc(*id), before.node_arc(*id)) {
+ (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
+ _ => true,
+ })
+ .collect()
+}
+
+/// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
+/// their children, instances follow their main components, auto layout re-flows.
+pub fn normalize(doc: &mut Document, before: &Document) {
+ // Only what was touched since the last normalisation (edited, added or removed).
+ let mut touched: BTreeSet = doc.take_dirty();
+ // A variable or style edit changes no layer directly, but bound and linked layers must follow it.
+ if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
+ return;
+ }
+ let m = measure::TextMeasure;
+ // 0. Variable bindings and style links feed node values.
+ let scope = binding_scope(doc, before, &touched);
+ if doc.variables != before.variables || doc.styles != before.styles {
+ style_vars::settle(doc);
+ }
+ cmd::styles::apply_bindings_to(doc, scope.as_ref());
+ style_vars::refresh(doc, Some(before), scope.as_ref());
+ if doc.styles != before.styles {
+ let changed_styles: Vec = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect();
+ for sid in changed_styles {
+ cmd::styles::propagate_style(doc, &sid);
+ }
+ }
+ touched.extend(doc.take_dirty());
+ // Lists and text chars edited away from their style unlink from it.
+ let live_now: Vec = touched.iter().copied().filter(|i| doc.contains(*i)).collect();
+ cmd::styles::sync_list_styles(doc, before, &live_now);
+ cmd::styles::sync_text_styles(doc, &live_now);
+ touched.extend(doc.take_dirty());
+ let live = |doc: &Document, t: &BTreeSet| -> Vec { t.iter().copied().filter(|i| doc.contains(*i)).collect() };
+ // 0b. Fill and hug only where they can apply (a layer moved out of auto layout, a parent that
+ // started to hug or lost auto layout): the rest becomes fixed at its current size.
+ for id in live(doc, &touched) {
+ figcraft_layout::coherent_sizing(doc, id);
+ }
+ touched.extend(doc.take_dirty());
+ // 1. Text auto-size (a sticky's note first takes the size its length calls for).
+ board::settle_text(doc, before, &touched);
+ touched.extend(doc.take_dirty());
+ for id in live(doc, &touched) {
+ let Some(n) = doc.node(id) else { continue };
+ if 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, id, w, h);
+ }
+ }
+ touched.extend(doc.take_dirty());
+ // 2. Instances follow edited main components (and record overrides inside instances).
+ let changed = live(doc, &touched);
+ components::sync_after_edit(doc, before, &changed, &touched);
+ // Rebuilt copies took their mains' values: their bindings resolve in their own modes.
+ let synced = doc.take_dirty();
+ if !synced.is_empty() {
+ cmd::styles::apply_bindings_to(doc, Some(&synced));
+ style_vars::refresh(doc, Some(before), Some(&synced));
+ touched.extend(synced);
+ }
+ touched.extend(doc.take_dirty());
+ // Parents of removed nodes (as they were before) count as touched.
+ let removed_parents: Vec =
+ touched.iter().filter(|i| !doc.contains(**i)).filter_map(|i| before.node(*i).and_then(|n| n.parent)).filter(|p| doc.contains(*p)).collect();
+ // 3. Groups / booleans wrap their children.
+ let mut fit: BTreeSet = BTreeSet::new();
+ for id in live(doc, &touched).into_iter().chain(removed_parents.iter().copied()) {
+ if doc.node(id).is_some_and(|n| n.kind.bounds_follow_children()) {
+ fit.insert(id);
+ }
+ for a in doc.ancestors(id) {
+ if doc.node(a).is_some_and(|n| n.kind.bounds_follow_children()) {
+ fit.insert(a);
+ } else {
+ break;
+ }
+ }
+ }
+ let mut fit: Vec = fit.into_iter().collect();
+ fit.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
+ for id in fit {
+ if doc.node(id).is_some_and(|n| n.children.is_empty()) {
+ // Empty groups disappear, like the incumbent.
+ let _ = doc.remove(id);
+ } else {
+ let _ = doc.fit_to_children(id);
+ }
+ }
+ touched.extend(doc.take_dirty());
+ // 4. Auto layout for frames where something that sizes or places layers changed.
+ let mut frames: BTreeSet = BTreeSet::new();
+ let moved = live(doc, &touched).into_iter().filter(|id| affects_layout(before, doc, *id));
+ for id in moved.chain(removed_parents) {
+ if !doc.contains(id) {
+ continue;
+ }
+ let mut chain = vec![id];
+ chain.extend(doc.ancestors(id));
+ for c in chain {
+ if doc.node(c).is_some_and(|n| n.layout.is_some()) {
+ frames.insert(c);
+ }
+ }
+ }
+ let mut frames: Vec = frames.into_iter().collect();
+ frames.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
+ for f in frames {
+ // Layers the layout leaves where they were stay untouched.
+ let kept: Vec<(Arc, bool)> = doc
+ .node(f)
+ .map(|n| std::iter::once(f).chain(n.children.iter().copied()).collect::>())
+ .unwrap_or_default()
+ .into_iter()
+ .filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i))))
+ .collect();
+ figcraft_layout::layout_frame(doc, f, &m);
+ for (a, was_dirty) in kept {
+ doc.keep_if_unchanged(a, was_dirty);
+ }
+ }
+ touched.extend(doc.take_dirty());
+ // 4b. Board connectors follow the layers they join; their labels sit at their middles.
+ board::settle_routes(doc, before, &touched);
+ touched.extend(doc.take_dirty());
+ // 5. Ready-for-dev layers whose contents changed show "Changed".
+ mark_dev_changes(doc, before, &touched);
+ doc.take_dirty();
+ // 5. Comment pins follow their layers to other pages and stay put when the layers are deleted.
+ cmd::comment::follow_anchors(doc, before);
+}
+
+/// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto
+/// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or
+/// layout settings.
+fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
+ let (Some(a), Some(b)) = (before.node(id), doc.node(id)) else { return true };
+ a.width != b.width
+ || a.height != b.height
+ || a.transform != b.transform
+ || a.visible != b.visible
+ || a.parent != b.parent
+ || a.children != b.children
+ || a.layout != b.layout
+ || a.child_layout != b.child_layout
+ || a.stroke != b.stroke
+ || a.strokes.len() != b.strokes.len()
+ || a.kind != b.kind
+}
+
+/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
+/// changed, else the touched layers and everything below those that moved, are new, chose
+/// another variable mode or became an instance of another main (modes are inherited, an
+/// instance's from its main too), and the instances of a main that chose another mode.
+fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> {
+ if doc.variables != before.variables {
+ return None;
+ }
+ let main = |n: &Node| match n.kind {
+ figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
+ _ => None,
+ };
+ let mut out = BTreeSet::new();
+ for &id in touched {
+ let Some(n) = doc.node(id) else { continue };
+ let Some(b) = before.node(id) else {
+ out.extend(doc.subtree(id));
+ continue;
+ };
+ let modes = b.var_modes != n.var_modes;
+ if modes || b.parent != n.parent || main(b) != main(n) {
+ out.extend(doc.subtree(id));
+ } else {
+ out.insert(id);
+ }
+ if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
+ for i in components::instances_of(doc, id) {
+ out.extend(doc.subtree(i));
+ }
+ }
+ }
+ Some(out)
+}
+
+/// Flag the frames and sections marked ready for dev (before this edit) that contain a layer whose
+/// design `touched` changed (added, removed or edited; the handoff status itself and hiding a
+/// section's contents don't count).
+fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet) {
+ let ready = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev);
+ let mut changed: BTreeSet = BTreeSet::new();
+ for &id in touched {
+ // Ready-for-dev frames around it first (the cheap test).
+ let mut chain = vec![id];
+ chain.extend(if doc.contains(id) { doc.ancestors(id) } else { before.ancestors(id) });
+ chain.retain(|m| ready(before, *m) && ready(doc, *m));
+ if chain.is_empty() {
+ continue;
+ }
+ let differs = match (before.node(id), doc.node(id)) {
+ (Some(a), Some(b)) => !a.same_design(b),
+ (None, None) => false,
+ _ => true,
+ };
+ if differs {
+ changed.extend(chain);
+ }
+ }
+ for m in changed {
+ if doc.node(m).is_some_and(|n| !n.dev_changed) {
+ // Its first change since it was marked: keep what it looked like (for Compare changes).
+ if let Some(snap) = before.snapshot_subtree(m) {
+ doc.dev_snapshots.insert(m, Arc::new(snap));
+ }
+ if let Some(n) = doc.node_mut(m) {
+ n.dev_changed = true;
+ }
+ }
+ }
+ // Snapshots last until the change is resolved, the layer is marked again or it's deleted.
+ if !doc.dev_snapshots.is_empty() {
+ let keep = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev && n.dev_changed);
+ let stale: Vec = doc.dev_snapshots.keys().copied().filter(|id| !keep(doc, *id)).collect();
+ for id in stale {
+ doc.dev_snapshots.remove(&id);
+ }
+ }
+}
diff --git a/crates/engine/src/dev.rs b/crates/engine/src/dev.rs
new file mode 100644
index 00000000..bc46aae9
--- /dev/null
+++ b/crates/engine/src/dev.rs
@@ -0,0 +1,9 @@
+pub mod annotations;
+pub mod code_connect;
+pub mod codegen;
+pub mod component_codegen;
+pub mod dev_measurements;
+pub mod dev_resources;
+pub mod devcompare;
+pub mod devinspect;
+pub mod tailwind;
diff --git a/crates/engine/src/annotations.rs b/crates/engine/src/dev/annotations.rs
similarity index 100%
rename from crates/engine/src/annotations.rs
rename to crates/engine/src/dev/annotations.rs
diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/dev/code_connect.rs
similarity index 75%
rename from crates/engine/src/code_connect.rs
rename to crates/engine/src/dev/code_connect.rs
index 08b3af02..271193e0 100644
--- a/crates/engine/src/code_connect.rs
+++ b/crates/engine/src/dev/code_connect.rs
@@ -37,7 +37,8 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option {
- CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
+ CodeLang::from_id(s)
+ .filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -64,8 +65,10 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
let path = &link.import_path;
match lang {
_ if path.is_empty() => String::new(),
- CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
+ CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
+ // A single-file component is a default export.
+ CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
_ => format!("import {path}"),
}
}
@@ -266,7 +269,10 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
+ // A Vue binding takes the component itself.
+ (Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
+ (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -279,22 +285,155 @@ fn pieces(t: &str) -> Vec> {
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
}
+/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`).
+fn inside_open_tag(s: &str) -> bool {
+ let mut in_tag = false;
+ let mut quote = None;
+ let mut chars = s.chars().peekable();
+ while let Some(c) = chars.next() {
+ match quote {
+ Some(q) => {
+ if c == q {
+ quote = None;
+ }
+ }
+ None => match c {
+ '<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
+ in_tag = true;
+ }
+ '>' if in_tag => in_tag = false,
+ '"' | '\'' if in_tag => quote = Some(c),
+ _ => {}
+ },
+ }
+ }
+ in_tag
+}
+
+/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the
+/// template already opened (if any). `None` when it isn't in an attribute at all.
+fn vue_attribute(out: &str) -> Option<(bool, Option)> {
+ let end = out.trim_end();
+ if !inside_open_tag(end) {
+ return None;
+ }
+ let (after, quote) = match end.chars().last() {
+ Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)),
+ _ => (end, None),
+ };
+ let after = after.trim_end();
+ let eq = after.rfind('=')?;
+ // The placeholder must follow the `=` (or its opening quote) directly; text after a closed
+ // attribute (``) is template content, not an attribute value.
+ if !after[eq + 1..].trim().is_empty() {
+ return None;
+ }
+ let before = after[..eq].trim_end();
+ let name: String = before
+ .chars()
+ .rev()
+ .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '@' | '.' | '[' | ']'))
+ .collect::>()
+ .into_iter()
+ .rev()
+ .collect();
+ if name.is_empty() {
+ return None;
+ }
+ let bound = name.starts_with(':') || name.starts_with("v-bind:") || name.starts_with('@') || name.starts_with("v-on:") || name.starts_with("v-");
+ Some((bound, quote))
+}
+
+/// An expression written into an HTML attribute opened with `quote`: its own delimiter and markup
+/// become entities, so Vue's parser hands the expression its string literal intact.
+fn html_attr_expr(e: &str, quote: Option