diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/Cargo.lock b/Cargo.lock index 413011ab4..555ed9afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,12 +942,60 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -972,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "wayland-client", ] @@ -1844,6 +1892,7 @@ version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", + "craft-ui", "craft-update", "egui", "egui_extras", @@ -1913,6 +1962,17 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2399,9 +2459,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2412,43 +2472,110 @@ dependencies = [ ] [[package]] -name = "icu_locale_core" -version = "2.4.0" +name = "icu_decimal" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.4.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -2461,18 +2588,21 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2480,6 +2610,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2731,7 +2885,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2844,6 +2998,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3684,6 +3841,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4681,6 +4840,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5165,6 +5337,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -6152,11 +6330,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6434,6 +6624,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index f6fca285a..fd2fbb811 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,13 +35,15 @@ figcraft-plugin = { path = "crates/plugin" } # craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -160,4 +162,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/ROADMAP.md b/ROADMAP.md index 869400331..fefaaad70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (FigJam generated vector backgrounds and outer-text fallback (#1341, #1397); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); 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 @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-27** · M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. - **2026-10-26** · M6, #1397 review: generated FigJam backgrounds retain saved vector networks without geometry paths. When generated text is missing or invalid, usable outer text is retained alongside successful generated children. Text fallback respects the layer budget and avoids duplicate text. Synthetic regressions cover these cases. Numbers unchanged. - **2026-10-26** · M6, #1341: FigJam generated sticky notes and shapes with text (#1341): backgrounds, text with its typography and paints, saved bounds and sticky author lines import as editable frame children; synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. @@ -351,6 +352,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M6, #1397: damaged generated FigJam shape geometry falls back to usable outer fill or stroke outlines before a rectangle; a surviving sticky author no longer suppresses the outer body-text fallback. Synthetic regressions cover both outlines, missing/invalid/blank bodies, author retention and layer budgets. Numbers unchanged. | | 2026-10-26 | minor | M6, #1397: generated FigJam backgrounds read saved vector networks before rectangle fallback; missing or invalid generated text uses the outer text without discarding successful children or exceeding the layer budget. Regression tests cover both paths. Numbers unchanged. | | 2026-10-26 | minor | FigJam generated sticky notes and shapes with text (#1341): backgrounds, text with its typography and paints, saved bounds and sticky author lines import as editable frame children; synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. | | 2026-10-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 | diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 1ea420a54..90fce92ba 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -52,6 +52,14 @@ use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; +mod backend; +mod files; +mod journal; +mod library; +mod links; + +pub use library::library_name; + pub const LOCK: &str = "LOCK"; pub const KEY: &str = "server.key"; pub const FILES: &str = "files"; @@ -541,847 +549,6 @@ impl Store { people: open.map_or(0, |o| o.people), } } - - /// Keep a document (its snapshot) as a new file with an edit link and a view link. - pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { - self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) - } - - /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's - /// caps it gets, and the files and bytes kept by the files published from addresses in it. An - /// address that doesn't parse counts as itself only, ×1, by its exact text. - fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { - let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); - let kept: Vec<(&str, Vec<_>, u64)> = self - .files - .iter() - .filter_map(|(id, e)| { - let from = e.meta.ip.as_deref()?; - Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) - }) - .collect(); - let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); - let blocks = blocks_of(ip); - if blocks.is_empty() { - let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); - return vec![(1, files, bytes)]; - } - blocks - .into_iter() - .map(|(block, times)| { - let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); - (times, files, bytes) - }) - .collect() - } - - /// Keep a document published without the key from `ip`, within the caps on what one address - /// and everyone may keep: the file, its links and its owner token. - pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { - let h = &self.cfg.hosting; - let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); - if !open { - return Err(KEY_ONLY.into()); - } - if !self.abuse.allow(ip, Action::Publish, Instant::now()) { - return Err("too many files published from your address lately: try again in an hour".into()); - } - let blocks = self.per_block(&ip.to_string()); - for &(times, files, _) in &blocks { - if files >= max_files.saturating_mul(times as usize) { - return Err(format!( - "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" - )); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - let fits = |n: u64| { - for &(times, _, used) in &blocks { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); - } - } - if used_all.saturating_add(n) > max_all { - return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); - } - Ok(()) - }; - let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; - Ok((info, edit, view, owner.unwrap_or_default())) - } - - /// Keep a document as a new file: published without the key from `ip` (it gets an owner - /// token), else by the operator. `fits` checks its size against the caps that apply. - fn keep( - &mut self, - name: &str, - snapshot: &[Change], - ip: Option, - fits: &dyn Fn(u64) -> Result<(), String>, - ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { - if self.files.len() >= self.cfg.max_files { - return Err(format!("the server keeps at most {} files", self.cfg.max_files)); - } - if let Some(why) = self.low_disk() { - return Err(why); - } - let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; - if replica.document().node_count() > self.cfg.core.max_nodes { - return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); - } - let mut doc = replica.document().clone(); - let name = match clean_name(name) { - n if n.is_empty() => doc.name.clone(), - n => n, - }; - doc.name = name.clone(); - let server = Server::new(doc, "", self.cfg.core); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(1, &state)?; - if bytes.len() as u64 > self.cfg.max_file_bytes { - return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); - } - fits(bytes.len() as u64)?; - let id = loop { - let id = token::file_id()?; - if !self.files.contains_key(&id) && !self.dir(&id).exists() { - break id; - } - }; - let t = now(); - let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; - let (view, view_token) = new_link(Access::View, "Published (view)", t)?; - let owner = ip.map(|_| token::secret()).transpose()?; - let meta = - Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; - let dir = self.dir(&id); - let written = fs::create_dir_all(&dir) - .map_err(|e| e.to_string()) - .and_then(|()| { - disk::sync_dir(Some(&self.root.join(FILES))); - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) - }) - .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) - .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) - // Last: a folder without its description is an upload cut short. - .and_then(|()| write_json(&dir.join(META), &meta)); - if let Err(e) = written { - let _ = fs::remove_dir_all(&dir); - return Err(format!("couldn't keep the file: {e}")); - } - let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; - self.files.insert(id.clone(), entry); - let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; - match ip { - Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), - None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), - } - Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) - } - - /// The files kept, with what the operator moderates by. - pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files - .iter() - .map(|(id, e)| FileRow { - info: self.info(id, e, open(id)), - opened: e.meta.opened, - expires: self.expires(&e.meta), - open: e.meta.owner.is_some(), - ip: e.meta.ip.clone().unwrap_or_default(), - reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), - }) - .collect() - } - - /// Owner token `owner` manages file `file` (one published without the key). - fn owns(&self, file: &str, owner: &str) -> bool { - let presented = hash(owner); - self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) - } - - /// What became of the files these `(file id, owner token)` pairs own: each one's name, links - /// and expiry, or `None` when that token manages no file of that id here (which doesn't say - /// whether the id exists). Asking opens nothing: it doesn't count as using a file. - pub fn owned(&self, files: &[(String, String)]) -> AdminReply { - if files.len() > MAX_OWNED { - return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; - } - let one = |(file, owner): &(String, String)| { - let e = self.files.get(file).filter(|_| self.owns(file, owner))?; - Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) - }; - AdminReply::Owned(files.iter().map(one).collect()) - } - - /// Rename a file (its listing; the document inside keeps its own name). - pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { - let name = clean_name(name); - if name.is_empty() { - return Err("give the file a name".into()); - } - let path = self.dir(file).join(META); - let entry = self.entry(file)?; - let before = std::mem::replace(&mut entry.meta.name, name); - if let Err(e) = write_json(&path, &entry.meta) { - entry.meta.name = before; - return Err(format!("couldn't rename the file: {e}")); - } - Ok(()) - } - - /// Delete the files published without the key that nobody opened for the idle time (not - /// those open now); their ids. - pub fn sweep(&mut self) -> Vec { - let t = now(); - self.next_sweep = t.saturating_add(SWEEP_EVERY); - let due: Vec = - self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); - for id in &due { - match self.remove(id) { - Ok(()) => log::info!("event=expired file={id}"), - Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), - } - } - due - } - - /// Make a file look unused for `secs` more (tests: expiry without waiting). - #[cfg(test)] - pub fn age(&mut self, file: &str, secs: u64) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - let m = &mut e.meta; - (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); - let _ = write_json(&path, m); - } - } - - /// Make an open file's log refuse writes (tests: a full or failing disk). - #[cfg(test)] - pub fn break_log(&mut self, file: &str) { - let path = self.dir(file).join(LOG); - if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) - && let Ok(f) = File::open(path) - { - log.file = f; - } - } - - /// Note that a file was opened now (it is used: it doesn't expire). - fn touch(&mut self, file: &str) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - e.meta.opened = now(); - if let Err(err) = write_json(&path, &e.meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - } - - /// What the server says about its hosting (with `file`'s expiry). - fn host_info(&self, file: Option<&str>) -> HostInfo { - let h = &self.cfg.hosting; - let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); - HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } - } - - /// Block or unblock an address (an IPv6 address: its /64); the address as kept. - pub fn block(&mut self, ip: &str, on: bool) -> Result { - let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); - if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { - return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); - } - let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; - if changed { - let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); - write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; - log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); - } - Ok(ip) - } - - /// The blocked addresses. - pub fn blocked(&self) -> Vec { - self.blocked.iter().map(IpAddr::to_string).collect() - } - - /// The reports kept, oldest first. - pub fn reports(&self) -> &[ReportInfo] { - &self.reports - } - - /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). - pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason = clean_reason(reason); - if reason.is_empty() { - return Err("say what is wrong with the file".into()); - } - let name = self.entry(file)?.meta.name.clone(); - if !self.abuse.allow(ip, Action::Report, Instant::now()) { - return Err("too many reports from your address lately: try again in an hour".into()); - } - self.reports.push(ReportInfo { - at: now(), - file: file.to_string(), - name, - link: link.unwrap_or_default().to_string(), - ip: ip.to_string(), - reason, - }); - if self.reports.len() > MAX_REPORTS { - let extra = self.reports.len() - MAX_REPORTS; - self.reports.drain(..extra); - } - write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; - log::info!("event=report file={file} ip={ip}"); - Ok(()) - } - - /// The files kept. - pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() - } - - /// A file's links. - pub fn links(&mut self, file: &str) -> Result, String> { - Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) - } - - /// A new link to a file. - pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { - let max = self.cfg.max_links; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - if entry.links.len() >= max { - return Err(format!("a file has at most {max} links: revoke one first")); - } - let (link, token) = new_link(access, &clean_name(label), now())?; - entry.links.push(link.clone()); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.pop(); - return Err(format!("couldn't keep the link: {e}")); - } - Ok(NewLink { link: link.info(file), token }) - } - - /// The link of `entry` whose token `token` is (every link is compared, in constant time, so - /// timing doesn't tell which one matched). - fn link_of(entry: &Entry, token: &str) -> Option { - let presented = hash(token); - let mut found = None; - for (i, l) in entry.links.iter().enumerate() { - if same_secret(&presented, &l.hash) { - found = Some(i); - } - } - found - } - - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); - let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); - self.gone_codes.drain(..extra); - } - - /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). - fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { - let mut found = None; - for (id, e) in &self.files { - for (i, l) in e.links.iter().enumerate() { - if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { - found = Some((id.clone(), i, token)); - } - } - } - found - } - - /// A new join code for the link of `file` whose token is `token` (it takes the place of the - /// link's earlier code): the code, shown this once. - pub fn set_code(&mut self, file: &str, token: &str) -> Result { - let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; - // 45 random bits: one already in use is all but impossible, and never handed out twice. - let (code, key) = loop { - let code = codes::new_code()?; - let key = codes::Key::new(&code); - if self.find_code(&key).is_none() { - break (code, key); - } - }; - let sealed = key.seal(token)?; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let link = entry.links.get_mut(at).ok_or("that file has no such link")?; - let before = link.code.replace(sealed); - let id = link.id.clone(); - if let Err(e) = write_json(&path, &entry.links) { - if let Some(l) = entry.links.get_mut(at) { - l.code = before; - } - return Err(format!("couldn't keep the join code: {e}")); - } - self.forget_codes(before); - log::info!("event=code file={file} link={id}"); - Ok(code) - } - - /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against - /// the address (see `abuse`), and nothing is looked up for an address that is locked out. - pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { - let now = Instant::now(); - if let Some(left) = self.abuse.code_locked(ip, now) { - return Err(format!( - "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", - left.as_secs() / 60 + 1 - )); - } - if !self.abuse.allow(ip, Action::Code, now) { - return Err("Too many codes tried from your address: wait a minute and try again".into()); - } - if !self.abuse.codes_open(now) { - return Err(CODES_BUSY.into()); - } - // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). - let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); - let found = key.as_ref().and_then(|k| self.find_code(k)); - self.abuse.code_tried(ip, found.is_some(), now); - match found { - Some((file, _, token)) => { - log::info!("event=code_used file={file} ip={ip}"); - Ok((file, token)) - } - None => { - let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); - Err(if gone { CODE_GONE } else { NO_CODE }.into()) - } - } - } - - /// Revoke a link. - pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; - let gone = entry.links.remove(at); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.insert(at, gone); - return Err(format!("couldn't revoke the link: {e}")); - } - log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); - Ok(()) - } - - /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no - /// longer served either way). - pub fn remove(&mut self, file: &str) -> Result<(), String> { - // Closed first: an open log would keep the folder from being deleted on Windows. - let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); - drop(entry); - let dir = self.dir(file); - fs::remove_dir_all(&dir) - .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; - log::info!("removed file {file}"); - Ok(()) - } - - /// A file's team library for `ip`, unless its version is `have`; within what one address and - /// everyone may download (the size is known before anything is read). - fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); - let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; - if have == Some(lib.version) { - return Ok(LibraryReply::Unchanged { version: lib.version }); - } - let path = library_file(&dir, lib.version); - let size = fs::metadata(&path).map_or(0, |m| m.len()); - let now = Instant::now(); - if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { - return Err(BUSY_DOWNLOADS.into()); - } - Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) - } - - /// Keep `archive` as a file's team library: it must read as a library (with the limits files - /// using it read it with), be the file's library if it has one, and be a newer version. - // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); - // move it to a worker if large libraries make that noticeable. - pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { - let max = self.cfg.max_file_bytes; - if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { - return Err(NO_PUBLIC_LIBRARY.into()); - } - if archive.len() as u64 > max { - return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); - } - // Everything it unpacks to counts against the same cap, so a small archive can't expand - // into a large library in memory. - let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { - Ok(Some(lib)) => lib, - Ok(None) => return Err("that isn't a published library".into()), - Err(e) => return Err(format!("the library can't be read: {e}")), - }; - if lib.doc.node_count() > self.cfg.core.max_nodes { - return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); - } - let m = &lib.manifest; - let dir = self.dir(file); - let entry = self.entry(file)?; - if let Some(old) = &entry.meta.library { - if old.id != m.id { - return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); - } - if m.version <= old.version { - return Err(format!("version {} is on the server already: publish again to follow it", old.version)); - } - // A jump would let a client use up the numbers and leave no newer version to publish. - if m.version > old.version.saturating_add(1) { - return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); - } - } else if m.version > MAX_FIRST_LIBRARY_VERSION { - return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); - } - // Each version has a file of its own, written before the description names it: a failed - // description write or a crash in between leaves the previous version served whole, never - // the new bytes under the old number. - let path = dir.join(library_name(m.version)); - disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; - let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); - if let Err(e) = write_json(&dir.join(META), &entry.meta) { - // Never served (the description doesn't name it); the next publish of it replaces it. - if let Err(r) = fs::remove_file(&path) { - log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); - } - entry.meta.library = before; - return Err(format!("couldn't keep the library: {e}")); - } - // The previous version isn't served any more. - if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { - log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); - } - log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); - Ok(m.version) - } - - /// A library's usage reports in memory (read from disk the first time; a damaged list starts - /// over: it is only statistics). `None` when the file has no library. - fn usage_of(&mut self, file: &str) -> Result, String> { - if self.entry(file)?.meta.library.is_none() { - return Ok(None); - } - if !self.usage.contains_key(file) { - let path = self.dir(file).join(USAGE); - let list = if path.exists() { - disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() - } else { - Vec::new() - }; - self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); - } - Ok(self.usage.get_mut(file)) - } - - /// The usage reports kept for a file's library (none when it has no library). - pub fn library_usage(&mut self, file: &str) -> Result, String> { - Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) - } - - /// Keep how one file uses a file's library (in memory; written when it changed, at most every - /// [`USAGE_WRITE_EVERY`] seconds). - pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { - let name = clean_name(&usage.file); - if name.is_empty() { - return Ok(()); - } - let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - components.sort(); - components.dedup_by_key(|c| c.0); - components.truncate(MAX_REPORTED); - let Some(u) = self.usage_of(file)? else { return Ok(()) }; - match u.list.iter_mut().find(|f| f.usage.file == name) { - Some(f) if f.usage.components == components => return Ok(()), - Some(f) => { - f.usage.components = components; - f.updated = now(); - } - None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), - } - if u.list.len() > MAX_USERS { - u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); - u.list.truncate(MAX_USERS); - } - u.dirty = true; - if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { - self.write_usage(false); - } - Ok(()) - } - - /// Write the usage reports that changed (`all`: whenever they were last written). - fn write_usage(&mut self, all: bool) { - let t = now(); - let root = self.root.join(FILES); - for (file, u) in &mut self.usage { - if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { - if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { - log::warn!("file {file}: library usage couldn't be kept: {e}"); - } - (u.dirty, u.written) = (false, t); - } - } - } - - /// Whether `file` has room for about `n` more bytes: its cap, and for a file published - /// without the key its address's share and everyone's. A log that took it over its cap is - /// compacted first (the state may be smaller than its history). - fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { - if let Some(why) = self.low_disk() { - return Err(why); - } - let max = self.cfg.max_file_bytes; - let over = |s: &Store| s.bytes(file).saturating_add(n) > max; - // A compaction to make room at most once a minute per file (it rewrites the whole file). - let t = now(); - let may_compact = self.files.get(file).is_some_and(|e| { - t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) - }); - if over(self) && may_compact { - if let Some(e) = self.files.get_mut(file) { - e.forced = t; - } - self.compact(file, server); - } - if over(self) { - return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); - } - let h = &self.cfg.hosting; - let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; - let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - for (times, _, used) in self.per_block(&ip) { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - if used_all.saturating_add(n) > max_all { - return Err("this server is full: that change wasn't kept".into()); - } - Ok(()) - } - - /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). - fn load(&mut self, file: &str) -> Result { - self.touch(file); - let dir = self.dir(file); - let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; - let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; - let mut issues = Vec::new(); - let mut fresh_log = true; - let mut kept = 0; - let log_path = dir.join(LOG); - // A log that's there but can't be read (an IO error, or over a lowered cap) may hold - // confirmed edits: the file isn't opened, and the log is left as it is for another try. - let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { - Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), - r => r, - }; - // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). - let read = match read.map(|b| disk::read_log(&b)) { - Ok(Err(why)) => { - let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); - fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; - Err(format!("{why}; it was moved aside to {}", aside.display())) - } - Ok(Ok(log)) => Ok(log), - Err(why) => Err(why), - }; - match read { - Ok(log) if log.generation == generation => { - fresh_log = false; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); - fresh_log = true; - break; - } - kept += 1; - } - if let Some(why) = log.damaged { - issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); - fresh_log = true; - } - } - // From before a compaction that was cut short, the snapshot has it all; or a generation - // field damaged, which no checksum covers. Replaying tells them apart without trusting - // it: entries the snapshot holds are skipped, so only edits it lacks change anything. - Ok(log) => { - let before = server.seq(); - let mut stopped = None; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - stopped = Some(e); - break; - } - } - let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); - if recovered > 0 { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ - {recovered} changes the snapshot didn't; they were recovered" - )); - } - if let Some(e) = stopped { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ - making sense after {recovered} newer changes ({e}); the rest was dropped" - )); - } - } - Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), - } - for i in issues { - log::warn!("{i}"); - self.note(i); - } - server.journaling(true); - let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); - let log = match (fresh_log, file_handle) { - (false, Ok(f)) => { - let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); - // Replayed entries make it worth compacting once everyone has left. - Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } - } - // A new snapshot and log of the next generation, so appends start clean. - _ => { - let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; - self.rewrite(file, next, &server).map_err(|e| e.why)? - } - }; - self.entry(file)?.log = Some(log); - Ok(server) - } - - /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the - /// document (recent batches for reconnecting clients, deletions), which may take it over. - fn snapshot_cap(&self) -> u64 { - self.cfg.max_file_bytes.saturating_mul(2) - } - - /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { - let dir = self.dir(file); - let kept = |why: String| RewriteError { why, snapshot_replaced: false }; - let state = server.state().map_err(|e| kept(e.to_string()))?; - let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; - // Never replace a readable snapshot with one that can't be opened again: the old snapshot - // and log still hold everything. - if bytes.len() as u64 > self.snapshot_cap() { - return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); - } - if bytes.len() as u64 > self.cfg.max_file_bytes { - log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); - } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; - // From here the old log no longer goes with the snapshot on disk. - let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; - if let Ok(e) = self.entry(file) { - e.bytes = (bytes.len() + LOG_HEADER) as u64; - e.meta.seq = server.seq(); - let meta = e.meta.clone(); - if let Err(err) = write_json(&dir.join(META), &meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) - } - - /// Compactions so far. - pub fn compactions(&self) -> u64 { - self.compactions - } - - /// Why nothing more may be written when the data directory's disk is nearly full (less free - /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space - /// can't be told, nothing is refused. - fn low_disk(&mut self) -> Option { - let now = Instant::now(); - let free = match self.free { - Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, - _ => { - let free = disk::free_bytes(&self.root); - self.free = Some((now, free)); - free - } - }; - free.filter(|f| *f < self.cfg.min_free_bytes) - .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) - } - - /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. - fn note(&mut self, problem: String) { - if self.problems.len() >= MAX_PROBLEMS { - self.problems.remove(0); - } - self.problems.push(problem); - } - - /// Compact an open file: its state as the next snapshot, an empty log. - fn compact(&mut self, file: &str, server: &Server) { - self.compactions += 1; - let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - let Some(next) = generation.checked_add(1) else { - log::error!("file {file}: compaction failed: the file's generation can't go any higher"); - return; - }; - match self.rewrite(file, next, server) { - Ok(log) => { - if let Ok(e) = self.entry(file) { - e.log = Some(log); - } - log::debug!("compacted file {file}"); - } - // The new snapshot holds everything, but a restart would drop whatever is appended to - // the old log now (its generation no longer matches): the file closes, the hub hears - // it at the next flush, and it opens again from the new snapshot. - Err(RewriteError { why, snapshot_replaced: true }) => { - self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); - log::error!("file {file}: compaction was cut short: {why}"); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - self.cut_short.push(file.to_string()); - } - // The old snapshot and log still hold everything: carry on appending. - Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), - } - } - - /// Write and sync what's pending in every open log; the files whose log couldn't be written. - /// Those are closed here (their pending records dropped: nobody was told of them) and open - /// again from what is on disk. - fn flush_all(&mut self) -> Vec { - let mut failed = std::mem::take(&mut self.cut_short); - for (id, e) in &mut self.files { - if let Some(log) = e.log.as_mut() { - match flush_log(id, log) { - Ok(n) => e.bytes += n, - Err(()) => { - e.log = None; - failed.push(id.clone()); - } - } - } - } - failed - } } /// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced @@ -1391,38 +558,6 @@ struct RewriteError { snapshot_replaced: bool, } -/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log -/// can't be trusted to hold them). -fn flush_log(id: &str, log: &mut Log) -> Result { - if log.pending.is_empty() { - return Ok(0); - } - match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { - Ok(()) => { - let n = log.pending.len() as u64; - log.bytes += n; - log.pending.clear(); - Ok(n) - } - Err(e) => { - log::error!("file {id}: couldn't write its log: {e}"); - Err(()) - } - } -} - -/// The name of the file holding version `version` of a file's team library. -pub fn library_name(version: u64) -> String { - format!("library-{version}.bin") -} - -/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a -/// version published before each version had one. -fn library_file(dir: &Path, version: u64) -> PathBuf { - let path = dir.join(library_name(version)); - if path.exists() { path } else { dir.join(LIBRARY) } -} - fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1433,243 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), let token = token::secret()?; Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } - -impl Backend for Store { - fn admit(&mut self, file: &str, token: &str) -> Result { - let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); - link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) - .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) - } - - fn open(&mut self, file: &str) -> Result { - self.load(file).map_err(|e| { - let why = format!("file {file} can't be opened: {e}"); - log::error!("{why}"); - self.note(why); - "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() - }) - } - - fn changed(&mut self, file: &str, server: &mut Server) { - let journal = server.take_journal(); - if journal.is_empty() { - return; - } - let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); - let Some(e) = self.files.get_mut(file) else { return }; - let Some(log) = e.log.as_mut() else { return }; - for j in &journal { - match disk::record(j) { - Ok(r) => log.pending.extend(r), - Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), - } - } - log.entries += journal.len(); - log.dirty = true; - e.meta.updated = now(); - if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { - // The snapshot holds what's pending too. - self.compact(file, server); - } - } - - fn flush(&mut self) -> Vec { - self.flush_all() - } - - fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { - self.room_for(file, server, bytes as u64) - } - - fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let compact = self.files.get_mut(file).is_some_and(|entry| { - let snapshot_seq = entry.meta.seq; - entry.log.as_mut().is_some_and(|log| { - // Issued client ids and refusals are durable in the log; only edits need a - // new image-bearing snapshot. Still try a snapshot if syncing the log failed. - flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) - }) - }); - if compact { - self.compact(file, server); - } - // It closes anyway, with its state in the snapshot and durable log. - self.cut_short.retain(|f| f != file); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - // Everyone just left: idle time counts from now. - self.touch(file); - true - } - - fn stop(&mut self, open: Vec<(&str, &mut Server)>) { - self.write_usage(true); - self.flush_all(); - for (file, server) in open { - if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { - self.compact(file, server); - } - } - } - - fn manage(&mut self, key: &str) -> Result<(), String> { - match same_secret(key, &self.key) { - true => Ok(()), - false if self.cfg.hosting.open => { - Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) - } - false => Err("wrong server key".into()), - } - } - - fn connect(&mut self, ip: IpAddr) -> Result<(), String> { - if now() >= self.next_sweep { - self.sweep(); - self.write_usage(false); - } - if self.blocked.contains(&ip) { - return Err("this server doesn't accept connections from your address".into()); - } - if !self.abuse.allow(ip, Action::Connect, Instant::now()) { - return Err("too many connections from your address lately: wait a minute and try again".into()); - } - Ok(()) - } - - fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { - // Cursors and cursor chat (many people behind one address each send ~30 a second while - // they move) have a budget of their own, beside everything else. - let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; - self.abuse.allow(ip, what, Instant::now()) - } - - fn hosting(&mut self, file: &str) -> Option { - Some(self.host_info(Some(file))) - } - - fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { - if file.is_empty() && owner.is_empty() { - return Ok(self.host_info(None)); - } - if !self.owns(file, owner) { - return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); - } - Ok(self.host_info(Some(file))) - } - - fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); - let file = match &req { - Admin::Publish { name, snapshot } => { - return match self.publish_open(ip, name, snapshot) { - Ok((file, edit, view, owner)) => { - let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); - AdminReply::Hosted { file, edit, view, owner, expires }.into() - } - Err(e) => { - log::info!("event=publish_refused ip={ip} reason={e:?}"); - Handled::from(AdminReply::Failed { reason: e }) - } - }; - } - Admin::Owned { files } => return self.owned(files).into(), - Admin::Links { file } - | Admin::CreateLink { file, .. } - | Admin::Revoke { file, .. } - | Admin::Rename { file, .. } - | Admin::Code { file, .. } - | Admin::Remove { file } => file, - _ => return failed("that needs the server key"), - }; - if owns != Some(file.as_str()) { - return failed("an owner token manages only its own file"); - } - self.admin(req, open) - } - - fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - self.keep_report(file, link, ip, reason) - } - - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); - match req { - Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { - Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), - Err(e) => failed(e), - }, - Admin::Files => AdminReply::Files(self.list(open)).into(), - Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), - Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), - Admin::Revoke { file, link } => match self.revoke(&file, &link) { - Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, - Err(e) => failed(e), - }, - Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), - Admin::Listing => AdminReply::Listing(self.listing(open)).into(), - Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), - Admin::Block { ip } => match self.block(&ip, true) { - Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, - Err(e) => failed(e), - }, - Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), - Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), - Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), - Admin::Owned { files } => self.owned(&files).into(), - // The hub removes people itself (it knows who is connected); it never asks the store. - Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - Admin::Remove { file } => { - if !self.files.contains_key(&file) { - return failed("this file isn't on the server (it may have been removed)".into()); - } - let reply = match self.remove(&file) { - Ok(()) => AdminReply::Done, - Err(reason) => AdminReply::Failed { reason }, - }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } - } - } - } - - fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { - let failed = |reason: String| LibraryReply::Failed { reason }; - match req { - LibraryReq::Fetch { have, usage } => { - if let Some(u) = usage - && let Err(e) = self.report_usage(file, u) - { - log::warn!("file {file}: a library usage report couldn't be kept: {e}"); - } - self.fetch_library(ip, file, have).unwrap_or_else(failed) - } - LibraryReq::Publish { .. } if access != Access::Edit => { - failed("you can only view this file: publishing its library needs an edit link".into()) - } - LibraryReq::Publish { archive } => { - self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) - } - LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), - } - } - - fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { - // One address's share first (before everyone's budget is spent or anything is read). - if let PluginReq::Fetch { id, version } = &req - && let Some(size) = self.directory.size_of(id, version.as_deref()) - && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) - { - return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; - } - self.directory.handle(publisher, req) - } - - fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { - self.resolve_code(ip, code) - } - - fn closing(&self) -> Closing { - Closing::Restarting - } -} diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs new file mode 100644 index 000000000..af9b8bbb9 --- /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 000000000..53ae9c875 --- /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 000000000..e39835435 --- /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 000000000..adb592db4 --- /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 000000000..ad30f4585 --- /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/clippy.toml b/clippy.toml index 724e05c5f..36cc87a4d 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,3 +4,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 1471c1798..363ecae08 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -46,1047 +46,27 @@ use crate::join::{ENDED, RESTARTING}; use crate::net::{Bucket, Duplex, Meter, NetLimits, Readers, config, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; -/// How long a closing connection waits for the peer to hang up. -const LINGER: Duration = Duration::from_secs(2); +mod backend; +mod conn; +mod hosting; +mod limits; +mod queue; -/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; -/// beyond that they are dropped. -pub const SIGNAL_RATE: f64 = 20.0; -pub const SIGNAL_BURST: f64 = 40.0; +pub use backend::{Backend, Closing, Handled, OpenFile}; +pub use conn::{SIGNAL_BURST, SIGNAL_RATE}; +use conn::{encode, reason, refuse, refused}; +pub use hosting::Hosting; +pub(crate) use hosting::{Local, clean_name}; +pub use limits::{Admission, Block, ip_blocks, ip_key}; +pub use queue::wide_block; +#[cfg(test)] +use queue::{BlockUse, Blocks}; +use queue::{Charges, HubIn, InFlight, Line, Out, Push}; /// Most messages the hub handles before it sends what they produced (bounds the delay a burst /// adds). const BURST: usize = 256; -/// What a link lets a hello do. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Admission { - pub access: Access, - /// The link's id, when the backend has several per file (revoking it disconnects whoever - /// joined with it). - pub link: Option, -} - -/// An admin request's reply, and what the hub must do about connections it affects. -#[derive(Clone, Debug, PartialEq)] -pub struct Handled { - pub reply: AdminReply, - /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. - pub revoked: Option<(String, String)>, - /// A file was removed: whoever is in it is disconnected and it is closed without saving. - pub removed: Option, - /// An address was blocked ([`ip_key`]): its connections are dropped. - pub blocked: Option, -} - -impl From for Handled { - fn from(reply: AdminReply) -> Self { - Self { reply, revoked: None, removed: None, blocked: None } - } -} - -/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address -/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). -pub fn ip_key(ip: IpAddr) -> IpAddr { - match ip.to_canonical() { - IpAddr::V6(v6) => { - let [a, b, c, d, ..] = v6.segments(); - IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) - } - v4 => v4, - } -} - -/// What everyone is told when a hub stops. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Closing { - /// The session is over (the app stopped hosting): final. - Ended, - /// The server is restarting: clients reconnect until it is back. - Restarting, -} - -/// What the hub knows of an open file (for listings). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct OpenFile { - /// People connected to it. - pub people: u32, - /// Its latest sequence number. - pub seq: u64, -} - -/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it -/// from its own thread only. -pub trait Backend: Send + 'static { - /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is - /// shown to the person joining; never echo the token). - fn admit(&mut self, file: &str, token: &str) -> Result; - - /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). - fn open(&mut self, file: &str) -> Result; - - /// The hub handled a message for an open file: keep what changed (its server's journal). - fn changed(&mut self, _file: &str, _server: &mut Server) {} - - /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers - /// after this. The files whose changes couldn't be made durable: nobody hears of them, the - /// files are closed (the backend forgets their open state) and everyone in them reconnects. - fn flush(&mut self) -> Vec { - Vec::new() - } - - /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and - /// the batch is refused. - fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { - Ok(()) - } - - /// Everyone left `file`: save it. True closes it (the next hello opens it again). - fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { - false - } - - /// The hub is stopping: save the open files. - fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} - - /// A managing connection's key: whether it may manage files. - fn manage(&mut self, _key: &str) -> Result<(), String> { - Err("this session is hosted by an app: it has no files to manage".into()) - } - - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// A request on a library connection to `file`, opened with a link of `access` (checked by - /// [`admit`](Self::admit)). - fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { - LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } - } - - /// A request on a plugin directory connection; `publisher`: it was opened with the right key - /// (checked by [`manage`](Self::manage)). - fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { - PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } - } - - /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the - /// client). - fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { - Ok(()) - } - - /// A message from a connection from `ip`: false disconnects it (that address sends too much). - fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { - true - } - - /// What to tell a client that reads it about the server's hosting, after its welcome to - /// `file` (`None`: nothing). - fn hosting(&mut self, _file: &str) -> Option { - None - } - - /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: - /// what the server says about its hosting, or why the connection is refused. - fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { - Err("this session is hosted by an app: it doesn't keep files for anyone".into()) - } - - /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it - /// was opened with. - fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. - fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { - Err("this session is hosted by an app: tell the person hosting it".into()) - } - - /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not - /// (shown to them; wrong codes are counted against the address). - fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { - Err("this session is hosted by an app: join it with its own code or invite link".into()) - } - - /// What everyone is told when the hub stops. - fn closing(&self) -> Closing; -} - -/// A message for one connection's writer: bytes (with what they count against, released when -/// they are sent or dropped), or the close. -enum Out { - Bytes(Bytes, Option), - Close(CloseCode, String), -} - -/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted -/// once against a shared counter (the server's, or a block's) until the last copy is sent or -/// dropped. -struct Held { - n: usize, - counter: Arc, -} - -impl Held { - fn new(n: usize, counter: &Arc) -> Arc { - counter.fetch_add(n, Ordering::Relaxed); - Arc::new(Held { n, counter: counter.clone() }) - } -} - -impl Drop for Held { - fn drop(&mut self) { - self.counter.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// What one message counts against, made by its first recipient and shared by the others: the -/// server's budget once, and each recipient block's once (twenty people behind one office's -/// address cost their block one copy of a broadcast, not twenty). -#[derive(Default)] -struct Charges { - all: Option>, - blocks: Vec<(usize, Arc)>, - /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each - /// connection's cap, and a whole office joining at once must fit). - skip_blocks: bool, -} - -/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through -/// [`Held`], once against the server's budget and once against its block's). -struct Charge { - n: usize, - mine: Arc, - _all: Arc, - _block: Option>, -} - -impl Drop for Charge { - fn drop(&mut self) { - self.mine.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// Why a message couldn't be queued. -#[derive(Debug, PartialEq, Eq)] -enum Push { - /// The connection's or its block's queue is full: it can't keep up. - Full, - /// The server's budget for queued bytes is used up. - Everyone, - /// The connection is gone. - Gone, -} - -/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its -/// block of addresses', and the server's for everyone), so a client that doesn't read can't make -/// the server hold more than that for it. -#[derive(Clone)] -struct Line { - tx: SyncSender, - queued: Arc, - block: Arc, - all: Arc, - max: usize, - max_block: usize, - max_all: usize, -} - -impl Line { - /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file - /// gets out). `charges` is what the message counts against, shared by its recipients; - /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer - /// to make room). - fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { - let n = b.len(); - let mine = self.queued.load(Ordering::Relaxed); - if mine > 0 && mine.saturating_add(n) > self.max { - return Err(Push::Full); - } - let key = Arc::as_ptr(&self.block) as usize; - let block = if charges.skip_blocks { - None - } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { - Some(h.clone()) - } else { - let used = self.block.load(Ordering::Relaxed); - if used > 0 && used.saturating_add(n) > self.max_block { - return Err(Push::Full); - } - let h = Held::new(n, &self.block); - charges.blocks.push((key, h.clone())); - Some(h) - }; - let all = match &charges.all { - Some(h) => h.clone(), - None => { - if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { - return Err(Push::Everyone); - } - charges.all.insert(Held::new(n, &self.all)).clone() - } - }; - self.queued.fetch_add(n, Ordering::Relaxed); - let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; - self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { - TrySendError::Full(_) => Push::Full, - TrySendError::Disconnected(_) => Push::Gone, - }) - } -} - -/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is -/// having read, shared by its connections. -#[derive(Clone, Default)] -struct BlockUse { - queued: Arc, - reading: Arc, -} - -/// The blocks with open connections; a block leaves when its last connection ends. -#[derive(Clone, Default)] -struct Blocks(Arc>>); - -impl Blocks { - /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, - /// whose connections' queues and readers are all gone) are forgotten first, so the table - /// never holds more than the blocks of open connections. - fn get(&self, key: IpAddr) -> BlockUse { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); - t.entry(key).or_default().clone() - } - - /// Blocks remembered. - fn len(&self) -> usize { - self.0.lock().unwrap_or_else(PoisonError::into_inner).len() - } -} - -/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an -/// IPv6 address's /48. -pub fn wide_block(key: IpAddr) -> IpAddr { - ip_blocks(key).last().map_or(key, |(b, _)| b.0) -} - -/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader -/// drops its connection instead of piling more on a busy hub. -#[derive(Clone)] -struct InFlight(Arc); - -impl InFlight { - fn take(&self, n: usize, max: usize) -> bool { - if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { - self.0.fetch_sub(n, Ordering::Relaxed); - return false; - } - true - } - - fn give(&self, n: usize) { - self.0.fetch_sub(n, Ordering::Relaxed); - } -} - -enum HubIn { - /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, - /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). - Open { - conn: u64, - ip: IpAddr, - first: ClientMsg, - out: Line, - sock: TcpStream, - /// Its size (in flight until handled). - bytes: usize, - /// Told when the hub takes the connection in (dropped unsent when it doesn't). - admitted: SyncSender<()>, - }, - Msg { - conn: u64, - msg: ClientMsg, - bytes: usize, - }, - Gone { - conn: u64, - }, - Local(Presence), - LocalMsg(ClientMsg), - /// The person hosting removes client `id` from their file (app sessions). - Remove(u32), - /// Cut every connection without a word (tests: a network failure). - #[cfg(test)] - Cut, - Stop, -} - -/// The person hosting an app session: in the file's session without a socket. -pub(crate) struct Local { - pub file: String, - pub server: Server, - /// Their client id (they joined `server` already). - pub id: u32, - pub inbox: Inbox, -} - -fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { - std::thread::Builder::new().name(name.to_string()).spawn(f) -} - -/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. -pub(crate) fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() -} - -/// A running hub: its listener, connections and hub thread. Dropping it stops it. -pub struct Hosting { - addr: SocketAddr, - hub: Sender, - stop: Arc, - open: Arc, - /// Bytes queued for every connection together, and in flight to the hub. - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - reading: Arc, - hub_thread: Option>, - accept_thread: Option>, -} - -impl Hosting { - /// Serve `backend`'s files on `listener`. - pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { - Self::start(listener, backend, limits, None) - } - - pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { - let addr = listener.local_addr()?; - let in_flight = InFlight(Arc::default()); - let mut hub = Hub { - backend, - rooms: HashMap::new(), - conns: HashMap::new(), - local: None, - outbox: Vec::new(), - in_flight: in_flight.clone(), - barred: HashMap::new(), - }; - if let Some(l) = local { - let mut room = Room::new(l.server); - room.local = Some((l.id, l.inbox)); - hub.rooms.insert(l.file.clone(), room); - hub.local = Some(l.file); - } - let (hub_tx, hub_rx) = channel(); - let stop = Arc::new(AtomicBool::new(false)); - let open = Arc::new(AtomicUsize::new(0)); - let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; - let shared = Shared { - per_address: PerAddress::default(), - queued: Arc::default(), - in_flight: in_flight.clone(), - blocks: Blocks::default(), - reading: Arc::default(), - readers: Readers::default(), - }; - let queued = shared.queued.clone(); - let blocks = shared.blocks.clone(); - let reading = shared.reading.clone(); - let accept_thread = { - let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); - spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) - }; - let accept_thread = match accept_thread { - Ok(t) => t, - Err(e) => { - let _ = hub_tx.send(HubIn::Stop); - let _ = hub_thread.join(); - return Err(e); - } - }; - log::info!("live session: listening on {addr}"); - Ok(Hosting { - addr, - hub: hub_tx, - stop, - open, - queued, - in_flight, - blocks, - reading, - hub_thread: Some(hub_thread), - accept_thread: Some(accept_thread), - }) - } - - /// Bytes waiting to be sent to clients, all connections together. - pub fn queued_bytes(&self) -> usize { - self.queued.load(Ordering::Relaxed) - } - - /// Blocks of addresses the server keeps counters for (those of open connections, and any - /// whose last connection just ended, until the next connection prunes them). - pub fn blocks_remembered(&self) -> usize { - self.blocks.len() - } - - /// Bytes being read into messages not whole yet, all connections together. - pub fn reading_bytes(&self) -> usize { - self.reading.load(Ordering::Relaxed) - } - - /// Bytes of messages read and not handled by the hub yet. - pub fn in_flight_bytes(&self) -> usize { - self.in_flight.0.load(Ordering::Relaxed) - } - - /// Where it listens. - pub fn addr(&self) -> SocketAddr { - self.addr - } - - /// Open connections (joined or not). - pub fn connections(&self) -> usize { - self.open.load(Ordering::Relaxed) - } - - /// The hosting person's presence (app sessions). - pub(crate) fn presence(&self, p: Presence) { - let _ = self.hub.send(HubIn::Local(p)); - } - - /// A message from the hosting person's sync client (their edits). - pub(crate) fn send(&self, msg: ClientMsg) { - let _ = self.hub.send(HubIn::LocalMsg(msg)); - } - - /// The hosting person removes client `id` from their file (app sessions; see - /// [`HubIn::Remove`]). - pub(crate) fn remove(&self, id: u32) { - let _ = self.hub.send(HubIn::Remove(id)); - } - - /// Cut every connection as a network failure would (no close frame). - #[cfg(test)] - pub(crate) fn cut_connections(&self) { - let _ = self.hub.send(HubIn::Cut); - } - - /// Stop: the backend saves what's open, everyone is told and disconnected, the port is - /// released. - pub fn stop(&mut self) { - if self.stop.swap(true, Ordering::SeqCst) { - return; - } - let _ = self.hub.send(HubIn::Stop); - if let Some(t) = self.hub_thread.take() { - let _ = t.join(); - } - // The accept loop checks the flag after each connection: wake it with one. - let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; - let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); - if let Some(t) = self.accept_thread.take() { - if woke { - let _ = t.join(); - } else { - log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); - } - } - // Readers end once their writers have closed the sockets. - let deadline = Instant::now() + Duration::from_secs(2); - while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - log::info!("live session: stopped listening on {}", self.addr); - } -} - -impl Drop for Hosting { - fn drop(&mut self) { - self.stop(); - } -} - -/// The loopback address of `addr`'s family. -fn loopback(addr: SocketAddr) -> std::net::IpAddr { - if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } -} - -/// Counts an open connection until dropped. -struct Open(Arc); - -impl Drop for Open { - fn drop(&mut self) { - self.0.fetch_sub(1, Ordering::Relaxed); - } -} - -/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a -/// /48 are different blocks). -pub type Block = (IpAddr, u8); - -/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each -/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 -/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. -pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { - let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; - let [a, b, c, d, ..] = v6.segments(); - vec![ - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), - ] -} - -/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address -/// leaves when its last one closes, so the table never holds more than the open connections. -#[derive(Clone, Default)] -struct PerAddress(Arc>>); - -/// One connection counted for a block until dropped. -struct Counted { - table: PerAddress, - ip: Block, -} - -impl PerAddress { - /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). - fn enter(&self, ip: Block, cap: usize) -> Option { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - let n = t.entry(ip).or_insert(0); - if cap > 0 && *n >= cap { - if *n == 0 { - t.remove(&ip); - } - return None; - } - *n += 1; - Some(Counted { table: self.clone(), ip }) - } - - /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider - /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of - /// `all` connections, so no block takes every connection. - fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { - let mut held = Vec::new(); - for ((addr, prefix), times) in ip_blocks(ip) { - let block_cap = match prefix { - 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), - 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), - _ => cap, - }; - held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); - } - Some(held) - } -} - -impl Drop for Counted { - fn drop(&mut self) { - let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(n) = t.get_mut(&self.ip) { - *n = n.saturating_sub(1); - if *n == 0 { - t.remove(&self.ip); - } - } - } -} - -/// Longest request line an uptime check may have. -const MAX_CHECK_LINE: usize = 2048; - -/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. -/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket -/// handshake goes on untouched. -fn health_check(stream: &mut TcpStream, until: Instant) -> bool { - let mut buf = [0u8; MAX_CHECK_LINE]; - let line = loop { - let n = match stream.peek(&mut buf) { - Ok(0) | Err(_) => return false, - Ok(n) => n, - }; - let seen = buf.get(..n).unwrap_or_default(); - if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { - return false; - } - if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { - break seen.get(..end).unwrap_or_default().to_vec(); - } - if n == buf.len() || Instant::now() >= until { - return false; - } - // The rest of the line is on its way (it nearly always arrives in one piece). - std::thread::sleep(Duration::from_millis(10)); - }; - let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); - let path = path.split(|b| *b == b'?').next().unwrap_or_default(); - if !path.ends_with(b"/healthz") { - return false; - } - // Read the request (its headers end with a blank line), so closing doesn't reset it. - let mut req = Vec::new(); - let mut chunk = [0u8; 1024]; - while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { - match stream.read(&mut chunk) { - Ok(0) | Err(_) => break, - Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), - } - } - let _ = stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); - let _ = stream.shutdown(std::net::Shutdown::Write); - true -} - -/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the -/// proxy added; any before it are whatever the client sent). -fn forwarded_for(req: &Request) -> Option { - let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; - let entry = last.rsplit(',').next()?.trim(); - entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) -} - -/// What every connection shares: the count per address, the bytes queued for all of them and the -/// bytes in flight to the hub. -#[derive(Clone)] -struct Shared { - per_address: PerAddress, - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - /// Bytes being read into messages, all connections together, and who is reading them. - reading: Arc, - readers: Readers, -} - -fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { - let mut next = 0u64; - for stream in listener.incoming() { - if stop.load(Ordering::SeqCst) { - break; - } - let stream = match stream { - Ok(s) => s, - Err(e) => { - // Out of sockets or similar: don't spin. - log::warn!("live session: accept failed: {e}"); - std::thread::sleep(Duration::from_millis(50)); - continue; - } - }; - if open.load(Ordering::Relaxed) >= limits.max_connections { - log::info!("live session: too many connections; refusing one"); - continue; - } - open.fetch_add(1, Ordering::Relaxed); - let guard = Open(open.clone()); - next += 1; - let (conn, hub, shared) = (next, hub.clone(), shared.clone()); - if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { - log::warn!("live session: can't start a connection thread: {e}"); - } - } -} - -/// A close reason that fits a WebSocket close frame (123 bytes). -fn reason(s: &str) -> String { - let mut end = s.len().min(123); - while !s.is_char_boundary(end) { - end -= 1; - } - s.get(..end).unwrap_or_default().to_string() -} - -fn encode(msg: &ServerMsg) -> Option { - match message::encode(msg) { - Ok(b) => Some(Bytes::from(b)), - Err(e) => { - log::warn!("live session: can't encode a message: {e}"); - None - } - } -} - -/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). -fn refuse(out: &Line, why: &str) { - refused(out, why); -} - -/// [`refuse`], and whether the close was queued (it isn't when the queue is full). -fn refused(out: &Line, why: &str) -> bool { - if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { - let _ = out.tx.try_send(Out::Bytes(b, None)); - } - out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() -} - -/// What the server tells a client when it is too busy to take more from it. -const BUSY: &str = "the server is busy: try again in a moment"; - -/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { - loop { - return match ws.read() { - Ok(Message::Binary(b)) => { - // Whole now: it counts as in flight from here, no longer as being read. - ws.get_mut().read_done(); - message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) - } - Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), - // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and - // a message trickled in is cut off by it (`Duplex::read_until`). - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) - if deadline.is_some_and(|d| Instant::now() >= d) => - { - Err("no hello in time".into()) - } - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, - Ok(Message::Close(_)) => Ok(None), - Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), - // The reading budget's refusal (see `Meter`). - Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), - Err(_) => Ok(None), - }; - } -} - -/// Why a message over `max` bytes is refused. -fn too_large(max: usize) -> String { - match max >> 20 { - 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), - mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), - } -} - -/// One connection's reader (the writer runs on its own thread). -fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { - let _ = stream.set_nodelay(true); - let hello_deadline = Instant::now() + limits.hello_timeout; - if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { - return; - } - if health_check(&mut stream, Instant::now() + limits.hello_timeout) { - return; - } - let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; - let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; - // The handshake and the hello must be in by the deadline, however slowly they trickle in. - rd.read_until(Some(hello_deadline)); - let cfg = config(limits.max_client_message); - let mut forwarded = None; - // The handshake callback's type is tungstenite's (its error is an HTTP response). - #[allow(clippy::result_large_err)] - let read_client = |req: &Request, resp: Response| -> Result { - if proxied { - forwarded = forwarded_for(req); - } - Ok(resp) - }; - let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { - Ok(ws) => ws, - Err(e) => { - log::debug!("live session: handshake failed: {e}"); - shutdown(&sock); - return; - } - }; - if proxied && forwarded.is_none() { - log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); - } - let ip = ip_key(forwarded.unwrap_or(peer)); - let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); - let wide = wide_block(ip); - let block = shared.blocks.get(wide); - ws.get_mut().meter(Meter::new( - conn, - &sock, - &shared.readers, - block.reading.clone(), - shared.reading.clone(), - limits.max_reading_block, - limits.max_reading_total, - )); - let (tx, rx) = sync_channel::(limits.queue.max(4)); - let out = Line { - tx, - queued: Arc::default(), - block: block.queued.clone(), - all: shared.queued.clone(), - max: limits.max_queued_bytes, - max_block: limits.max_queued_block, - max_all: limits.max_queued_total, - }; - let writer = - match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; - let end = |why: Option<&str>| { - if let Some(why) = why { - refuse(&out, why); - } else { - let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); - } - }; - if counted.is_none() { - log::info!("event=refused ip={ip} reason=connections_per_address"); - end(Some("too many connections from your address: close some and try again")); - } else { - let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); - // In time or not, the hello's deadline is over: what's read next has timeouts of its own. - ws.get_mut().read_until(None); - match hello { - Ok(Some(( - first @ (ClientMsg::Hello(_) - | ClientMsg::Manage(_) - | ClientMsg::OpenLibrary(_) - | ClientMsg::OpenPlugins(_) - | ClientMsg::OpenHosting(_) - | ClientMsg::Resolve(_)), - bytes, - ))) => { - // Sessions and the operator may use the last connections; one-shot requests - // (hosting, libraries, browsing plugins) leave them free. - let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); - // A publish uploads a whole file; a large one on a slow link takes the server a - // while to receive and answer. The manage/hosting connections that carry uploads - // wait the longer request-reply timeout, not the short request idle. - let idle = match first { - ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, - ClientMsg::Hello(_) => limits.idle_timeout, - _ => limits.request_idle, - }; - let max = match &first { - ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, - _ => limits.max_client_message, - }; - let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); - // (In flight is only taken when the connection goes on: the hub gives it back.) - if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { - end(Some(BUSY)); - } else { - let (admitted, verdict) = sync_channel(1); - let opened = - sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); - // Nothing more is read until the hub took the connection in (its token checked): - // only then may it send messages of the full size. - if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { - ws.set_config(|c| *c = config(max)); - read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); - let _ = hub.send(HubIn::Gone { conn }); - } - } - } - Ok(Some(_)) => end(Some("say hello first")), - Ok(None) => {} - Err(why) => end(Some(&why)), - } - } - drop(counted); - // The block's counters go with the last handle on them (the table forgets it then). - drop(block); - ws.get_mut().read_until(None); - linger(&mut ws); - drop(out); - // The writer closes the socket once it has sent what's queued (a refusal, a close). - let _ = writer.join(); - shutdown(&sock); -} - -fn read_loop( - ws: &mut WebSocket, - conn: u64, - hub: &Sender, - limits: NetLimits, - max: usize, - in_flight: &InFlight, - end: &dyn Fn(Option<&str>), -) { - let mut bucket = Bucket::new(limits.rate, limits.burst); - let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); - loop { - match next_message(ws, max, None) { - Ok(Some((msg, bytes))) => { - let now = Instant::now(); - if !bucket.take(now) { - log::info!("live session: connection {conn} sends too much; disconnecting it"); - return end(Some("too many messages")); - } - // Typing sends a chat signal per keystroke; more than this is dropped. - if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { - log::debug!("live session: connection {conn} sends signals too fast; dropping one"); - continue; - } - if !in_flight.take(bytes, limits.max_in_flight) { - log::info!("live session: the hub is behind; dropping connection {conn}"); - return end(Some(BUSY)); - } - if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { - return; - } - } - Ok(None) => return end(None), - Err(why) => return end(Some(&why)), - } - } -} - -fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { - let mut close = Some((CloseCode::Normal, String::new())); - while let Ok(o) = rx.recv() { - match o { - Out::Bytes(b, _charge) => { - if ws.send(Message::Binary(b)).is_err() { - close = None; - break; - } - } - Out::Close(code, why) => { - close = Some((code, why)); - break; - } - } - } - if let Some((code, why)) = close { - // Close politely: the close frame, then no more writing; the reader drains what the peer - // still sends until it hangs up (closing on unread data would reset the connection and - // could lose the close frame), or until the linger runs out. - let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); - let _ = sock.shutdown(std::net::Shutdown::Write); - let until = Instant::now() + LINGER; - while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { - if Instant::now() >= until { - break; - } - } - } - shutdown(sock); -} - -/// Read and drop what the peer still sends after the close, until it hangs up (at most -/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader -/// is mid-frame and mustn't be asked for more. -fn linger(ws: &mut WebSocket) { - let until = Instant::now() + LINGER; - let mut buf = [0u8; 4096]; - while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { - match ws.get_mut().read(&mut buf) { - Ok(0) => break, - Ok(_) => {} - Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} - Err(_) => break, - } - } -} - /// What a connection is for. enum Kind { /// In `file`'s session as client `id`, through link `link`. @@ -1783,13 +763,10 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { } #[cfg(test)] -#[path = "queue_tests.rs"] mod queue_tests; #[cfg(test)] -#[path = "barred_tests.rs"] mod barred_tests; #[cfg(test)] -#[path = "preauth_tests.rs"] mod preauth_tests; diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs new file mode 100644 index 000000000..04c5363d9 --- /dev/null +++ b/crates/collab-net/src/hub/backend.rs @@ -0,0 +1,138 @@ +use super::limits::Admission; +use super::*; + +/// An admin request's reply, and what the hub must do about connections it affects. +#[derive(Clone, Debug, PartialEq)] +pub struct Handled { + pub reply: AdminReply, + /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. + pub revoked: Option<(String, String)>, + /// A file was removed: whoever is in it is disconnected and it is closed without saving. + pub removed: Option, + /// An address was blocked ([`ip_key`]): its connections are dropped. + pub blocked: Option, +} + +impl From for Handled { + fn from(reply: AdminReply) -> Self { + Self { reply, revoked: None, removed: None, blocked: None } + } +} + +/// What everyone is told when a hub stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Closing { + /// The session is over (the app stopped hosting): final. + Ended, + /// The server is restarting: clients reconnect until it is back. + Restarting, +} + +/// What the hub knows of an open file (for listings). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OpenFile { + /// People connected to it. + pub people: u32, + /// Its latest sequence number. + pub seq: u64, +} + +/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it +/// from its own thread only. +pub trait Backend: Send + 'static { + /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is + /// shown to the person joining; never echo the token). + fn admit(&mut self, file: &str, token: &str) -> Result; + + /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). + fn open(&mut self, file: &str) -> Result; + + /// The hub handled a message for an open file: keep what changed (its server's journal). + fn changed(&mut self, _file: &str, _server: &mut Server) {} + + /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers + /// after this. The files whose changes couldn't be made durable: nobody hears of them, the + /// files are closed (the backend forgets their open state) and everyone in them reconnects. + fn flush(&mut self) -> Vec { + Vec::new() + } + + /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and + /// the batch is refused. + fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { + Ok(()) + } + + /// Everyone left `file`: save it. True closes it (the next hello opens it again). + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + false + } + + /// The hub is stopping: save the open files. + fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} + + /// A managing connection's key: whether it may manage files. + fn manage(&mut self, _key: &str) -> Result<(), String> { + Err("this session is hosted by an app: it has no files to manage".into()) + } + + /// A request on a managing connection; `open` tells about a file that is open. + fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// A request on a library connection to `file`, opened with a link of `access` (checked by + /// [`admit`](Self::admit)). + fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { + LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } + } + + /// A request on a plugin directory connection; `publisher`: it was opened with the right key + /// (checked by [`manage`](Self::manage)). + fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { + PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } + } + + /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the + /// client). + fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { + Ok(()) + } + + /// A message from a connection from `ip`: false disconnects it (that address sends too much). + fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { + true + } + + /// What to tell a client that reads it about the server's hosting, after its welcome to + /// `file` (`None`: nothing). + fn hosting(&mut self, _file: &str) -> Option { + None + } + + /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: + /// what the server says about its hosting, or why the connection is refused. + fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { + Err("this session is hosted by an app: it doesn't keep files for anyone".into()) + } + + /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it + /// was opened with. + fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. + fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { + Err("this session is hosted by an app: tell the person hosting it".into()) + } + + /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not + /// (shown to them; wrong codes are counted against the address). + fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { + Err("this session is hosted by an app: join it with its own code or invite link".into()) + } + + /// What everyone is told when the hub stops. + fn closing(&self) -> Closing; +} diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/hub/barred_tests.rs similarity index 100% rename from crates/collab-net/src/barred_tests.rs rename to crates/collab-net/src/hub/barred_tests.rs diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs new file mode 100644 index 000000000..109dfed9f --- /dev/null +++ b/crates/collab-net/src/hub/conn.rs @@ -0,0 +1,404 @@ +use super::hosting::{Open, spawn}; +use super::limits::{PerAddress, ip_key}; +use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block}; +use super::*; + +/// How long a closing connection waits for the peer to hang up. +const LINGER: Duration = Duration::from_secs(2); + +/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; +/// beyond that they are dropped. +pub const SIGNAL_RATE: f64 = 20.0; + +pub const SIGNAL_BURST: f64 = 40.0; + +/// Longest request line an uptime check may have. +const MAX_CHECK_LINE: usize = 2048; + +/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. +/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket +/// handshake goes on untouched. +fn health_check(stream: &mut TcpStream, until: Instant) -> bool { + let mut buf = [0u8; MAX_CHECK_LINE]; + let line = loop { + let n = match stream.peek(&mut buf) { + Ok(0) | Err(_) => return false, + Ok(n) => n, + }; + let seen = buf.get(..n).unwrap_or_default(); + if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { + return false; + } + if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { + break seen.get(..end).unwrap_or_default().to_vec(); + } + if n == buf.len() || Instant::now() >= until { + return false; + } + // The rest of the line is on its way (it nearly always arrives in one piece). + std::thread::sleep(Duration::from_millis(10)); + }; + let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); + let path = path.split(|b| *b == b'?').next().unwrap_or_default(); + if !path.ends_with(b"/healthz") { + return false; + } + // Read the request (its headers end with a blank line), so closing doesn't reset it. + let mut req = Vec::new(); + let mut chunk = [0u8; 1024]; + while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), + } + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); + let _ = stream.shutdown(std::net::Shutdown::Write); + true +} + +/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the +/// proxy added; any before it are whatever the client sent). +fn forwarded_for(req: &Request) -> Option { + let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; + let entry = last.rsplit(',').next()?.trim(); + entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) +} + +/// What every connection shares: the count per address, the bytes queued for all of them and the +/// bytes in flight to the hub. +#[derive(Clone)] +pub(super) struct Shared { + pub(super) per_address: PerAddress, + pub(super) queued: Arc, + pub(super) in_flight: InFlight, + pub(super) blocks: Blocks, + /// Bytes being read into messages, all connections together, and who is reading them. + pub(super) reading: Arc, + pub(super) readers: Readers, +} + +pub(super) fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { + let mut next = 0u64; + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + let stream = match stream { + Ok(s) => s, + Err(e) => { + // Out of sockets or similar: don't spin. + log::warn!("live session: accept failed: {e}"); + std::thread::sleep(Duration::from_millis(50)); + continue; + } + }; + if open.load(Ordering::Relaxed) >= limits.max_connections { + log::info!("live session: too many connections; refusing one"); + continue; + } + open.fetch_add(1, Ordering::Relaxed); + let guard = Open(open.clone()); + next += 1; + let (conn, hub, shared) = (next, hub.clone(), shared.clone()); + if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { + log::warn!("live session: can't start a connection thread: {e}"); + } + } +} + +/// A close reason that fits a WebSocket close frame (123 bytes). +pub(super) fn reason(s: &str) -> String { + let mut end = s.len().min(123); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or_default().to_string() +} + +pub(super) fn encode(msg: &ServerMsg) -> Option { + match message::encode(msg) { + Ok(b) => Some(Bytes::from(b)), + Err(e) => { + log::warn!("live session: can't encode a message: {e}"); + None + } + } +} + +/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). +pub(super) fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +pub(super) fn refused(out: &Line, why: &str) -> bool { + if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { + let _ = out.tx.try_send(Out::Bytes(b, None)); + } + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() +} + +/// What the server tells a client when it is too busy to take more from it. +const BUSY: &str = "the server is busy: try again in a moment"; + +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { + loop { + return match ws.read() { + Ok(Message::Binary(b)) => { + // Whole now: it counts as in flight from here, no longer as being read. + ws.get_mut().read_done(); + message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) + } + Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, + Ok(Message::Close(_)) => Ok(None), + Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), + // The reading budget's refusal (see `Meter`). + Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), + Err(_) => Ok(None), + }; + } +} + +/// Why a message over `max` bytes is refused. +fn too_large(max: usize) -> String { + match max >> 20 { + 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), + mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), + } +} + +/// One connection's reader (the writer runs on its own thread). +fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { + let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; + if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { + return; + } + if health_check(&mut stream, Instant::now() + limits.hello_timeout) { + return; + } + let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; + let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); + let cfg = config(limits.max_client_message); + let mut forwarded = None; + // The handshake callback's type is tungstenite's (its error is an HTTP response). + #[allow(clippy::result_large_err)] + let read_client = |req: &Request, resp: Response| -> Result { + if proxied { + forwarded = forwarded_for(req); + } + Ok(resp) + }; + let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { + Ok(ws) => ws, + Err(e) => { + log::debug!("live session: handshake failed: {e}"); + shutdown(&sock); + return; + } + }; + if proxied && forwarded.is_none() { + log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); + } + let ip = ip_key(forwarded.unwrap_or(peer)); + let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); + let wide = wide_block(ip); + let block = shared.blocks.get(wide); + ws.get_mut().meter(Meter::new( + conn, + &sock, + &shared.readers, + block.reading.clone(), + shared.reading.clone(), + limits.max_reading_block, + limits.max_reading_total, + )); + let (tx, rx) = sync_channel::(limits.queue.max(4)); + let out = Line { + tx, + queued: Arc::default(), + block: block.queued.clone(), + all: shared.queued.clone(), + max: limits.max_queued_bytes, + max_block: limits.max_queued_block, + max_all: limits.max_queued_total, + }; + let writer = + match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; + let end = |why: Option<&str>| { + if let Some(why) = why { + refuse(&out, why); + } else { + let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); + } + }; + if counted.is_none() { + log::info!("event=refused ip={ip} reason=connections_per_address"); + end(Some("too many connections from your address: close some and try again")); + } else { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { + Ok(Some(( + first @ (ClientMsg::Hello(_) + | ClientMsg::Manage(_) + | ClientMsg::OpenLibrary(_) + | ClientMsg::OpenPlugins(_) + | ClientMsg::OpenHosting(_) + | ClientMsg::Resolve(_)), + bytes, + ))) => { + // Sessions and the operator may use the last connections; one-shot requests + // (hosting, libraries, browsing plugins) leave them free. + let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; + let max = match &first { + ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, + _ => limits.max_client_message, + }; + let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); + // (In flight is only taken when the connection goes on: the hub gives it back.) + if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { + end(Some(BUSY)); + } else { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); + read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); + let _ = hub.send(HubIn::Gone { conn }); + } + } + } + Ok(Some(_)) => end(Some("say hello first")), + Ok(None) => {} + Err(why) => end(Some(&why)), + } + } + drop(counted); + // The block's counters go with the last handle on them (the table forgets it then). + drop(block); + ws.get_mut().read_until(None); + linger(&mut ws); + drop(out); + // The writer closes the socket once it has sent what's queued (a refusal, a close). + let _ = writer.join(); + shutdown(&sock); +} + +fn read_loop( + ws: &mut WebSocket, + conn: u64, + hub: &Sender, + limits: NetLimits, + max: usize, + in_flight: &InFlight, + end: &dyn Fn(Option<&str>), +) { + let mut bucket = Bucket::new(limits.rate, limits.burst); + let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); + loop { + match next_message(ws, max, None) { + Ok(Some((msg, bytes))) => { + let now = Instant::now(); + if !bucket.take(now) { + log::info!("live session: connection {conn} sends too much; disconnecting it"); + return end(Some("too many messages")); + } + // Typing sends a chat signal per keystroke; more than this is dropped. + if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { + log::debug!("live session: connection {conn} sends signals too fast; dropping one"); + continue; + } + if !in_flight.take(bytes, limits.max_in_flight) { + log::info!("live session: the hub is behind; dropping connection {conn}"); + return end(Some(BUSY)); + } + if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { + return; + } + } + Ok(None) => return end(None), + Err(why) => return end(Some(&why)), + } + } +} + +fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { + let mut close = Some((CloseCode::Normal, String::new())); + while let Ok(o) = rx.recv() { + match o { + Out::Bytes(b, _charge) => { + if ws.send(Message::Binary(b)).is_err() { + close = None; + break; + } + } + Out::Close(code, why) => { + close = Some((code, why)); + break; + } + } + } + if let Some((code, why)) = close { + // Close politely: the close frame, then no more writing; the reader drains what the peer + // still sends until it hangs up (closing on unread data would reset the connection and + // could lose the close frame), or until the linger runs out. + let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); + let _ = sock.shutdown(std::net::Shutdown::Write); + let until = Instant::now() + LINGER; + while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { + if Instant::now() >= until { + break; + } + } + } + shutdown(sock); +} + +/// Read and drop what the peer still sends after the close, until it hangs up (at most +/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader +/// is mid-frame and mustn't be asked for more. +fn linger(ws: &mut WebSocket) { + let until = Instant::now() + LINGER; + let mut buf = [0u8; 4096]; + while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { + match ws.get_mut().read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} + Err(_) => break, + } + } +} diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs new file mode 100644 index 000000000..8588e56ab --- /dev/null +++ b/crates/collab-net/src/hub/hosting.rs @@ -0,0 +1,201 @@ +use super::backend::Backend; +use super::conn::{Shared, accept}; +use super::limits::{PerAddress, loopback}; +use super::queue::{Blocks, HubIn, InFlight}; +use super::*; + +/// The person hosting an app session: in the file's session without a socket. +pub(crate) struct Local { + pub file: String, + pub server: Server, + /// Their client id (they joined `server` already). + pub id: u32, + pub inbox: Inbox, +} + +pub(super) fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { + std::thread::Builder::new().name(name.to_string()).spawn(f) +} + +/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. +pub(crate) fn clean_name(s: &str) -> String { + s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() +} + +/// A running hub: its listener, connections and hub thread. Dropping it stops it. +pub struct Hosting { + addr: SocketAddr, + hub: Sender, + stop: Arc, + open: Arc, + /// Bytes queued for every connection together, and in flight to the hub. + queued: Arc, + in_flight: InFlight, + blocks: Blocks, + reading: Arc, + hub_thread: Option>, + accept_thread: Option>, +} + +impl Hosting { + /// Serve `backend`'s files on `listener`. + pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { + Self::start(listener, backend, limits, None) + } + + pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { + let addr = listener.local_addr()?; + let in_flight = InFlight(Arc::default()); + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; + if let Some(l) = local { + let mut room = Room::new(l.server); + room.local = Some((l.id, l.inbox)); + hub.rooms.insert(l.file.clone(), room); + hub.local = Some(l.file); + } + let (hub_tx, hub_rx) = channel(); + let stop = Arc::new(AtomicBool::new(false)); + let open = Arc::new(AtomicUsize::new(0)); + let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; + let shared = Shared { + per_address: PerAddress::default(), + queued: Arc::default(), + in_flight: in_flight.clone(), + blocks: Blocks::default(), + reading: Arc::default(), + readers: Readers::default(), + }; + let queued = shared.queued.clone(); + let blocks = shared.blocks.clone(); + let reading = shared.reading.clone(); + let accept_thread = { + let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); + spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) + }; + let accept_thread = match accept_thread { + Ok(t) => t, + Err(e) => { + let _ = hub_tx.send(HubIn::Stop); + let _ = hub_thread.join(); + return Err(e); + } + }; + log::info!("live session: listening on {addr}"); + Ok(Hosting { + addr, + hub: hub_tx, + stop, + open, + queued, + in_flight, + blocks, + reading, + hub_thread: Some(hub_thread), + accept_thread: Some(accept_thread), + }) + } + + /// Bytes waiting to be sent to clients, all connections together. + pub fn queued_bytes(&self) -> usize { + self.queued.load(Ordering::Relaxed) + } + + /// Blocks of addresses the server keeps counters for (those of open connections, and any + /// whose last connection just ended, until the next connection prunes them). + pub fn blocks_remembered(&self) -> usize { + self.blocks.len() + } + + /// Bytes being read into messages not whole yet, all connections together. + pub fn reading_bytes(&self) -> usize { + self.reading.load(Ordering::Relaxed) + } + + /// Bytes of messages read and not handled by the hub yet. + pub fn in_flight_bytes(&self) -> usize { + self.in_flight.0.load(Ordering::Relaxed) + } + + /// Where it listens. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Open connections (joined or not). + pub fn connections(&self) -> usize { + self.open.load(Ordering::Relaxed) + } + + /// The hosting person's presence (app sessions). + pub(crate) fn presence(&self, p: Presence) { + let _ = self.hub.send(HubIn::Local(p)); + } + + /// A message from the hosting person's sync client (their edits). + pub(crate) fn send(&self, msg: ClientMsg) { + let _ = self.hub.send(HubIn::LocalMsg(msg)); + } + + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + + /// Cut every connection as a network failure would (no close frame). + #[cfg(test)] + pub(crate) fn cut_connections(&self) { + let _ = self.hub.send(HubIn::Cut); + } + + /// Stop: the backend saves what's open, everyone is told and disconnected, the port is + /// released. + pub fn stop(&mut self) { + if self.stop.swap(true, Ordering::SeqCst) { + return; + } + let _ = self.hub.send(HubIn::Stop); + if let Some(t) = self.hub_thread.take() { + let _ = t.join(); + } + // The accept loop checks the flag after each connection: wake it with one. + let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; + let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); + if let Some(t) = self.accept_thread.take() { + if woke { + let _ = t.join(); + } else { + log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); + } + } + // Readers end once their writers have closed the sockets. + let deadline = Instant::now() + Duration::from_secs(2); + while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + log::info!("live session: stopped listening on {}", self.addr); + } +} + +impl Drop for Hosting { + fn drop(&mut self) { + self.stop(); + } +} + +/// Counts an open connection until dropped. +pub(super) struct Open(pub(super) Arc); + +impl Drop for Open { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs new file mode 100644 index 000000000..333ff165e --- /dev/null +++ b/crates/collab-net/src/hub/limits.rs @@ -0,0 +1,99 @@ +use super::*; + +/// What a link lets a hello do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Admission { + pub access: Access, + /// The link's id, when the backend has several per file (revoking it disconnects whoever + /// joined with it). + pub link: Option, +} + +/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address +/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). +pub fn ip_key(ip: IpAddr) -> IpAddr { + match ip.to_canonical() { + IpAddr::V6(v6) => { + let [a, b, c, d, ..] = v6.segments(); + IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) + } + v4 => v4, + } +} + +/// The loopback address of `addr`'s family. +pub(super) fn loopback(addr: SocketAddr) -> std::net::IpAddr { + if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } +} + +/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a +/// /48 are different blocks). +pub type Block = (IpAddr, u8); + +/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each +/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 +/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. +pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { + let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; + let [a, b, c, d, ..] = v6.segments(); + vec![ + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), + ] +} + +/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address +/// leaves when its last one closes, so the table never holds more than the open connections. +#[derive(Clone, Default)] +pub(super) struct PerAddress(pub(super) Arc>>); + +/// One connection counted for a block until dropped. +pub(super) struct Counted { + table: PerAddress, + ip: Block, +} + +impl PerAddress { + /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). + fn enter(&self, ip: Block, cap: usize) -> Option { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let n = t.entry(ip).or_insert(0); + if cap > 0 && *n >= cap { + if *n == 0 { + t.remove(&ip); + } + return None; + } + *n += 1; + Some(Counted { table: self.clone(), ip }) + } + + /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider + /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of + /// `all` connections, so no block takes every connection. + pub(super) fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { + let mut held = Vec::new(); + for ((addr, prefix), times) in ip_blocks(ip) { + let block_cap = match prefix { + 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), + 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), + _ => cap, + }; + held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); + } + Some(held) + } +} + +impl Drop for Counted { + fn drop(&mut self) { + let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(n) = t.get_mut(&self.ip) { + *n = n.saturating_sub(1); + if *n == 0 { + t.remove(&self.ip); + } + } + } +} diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/hub/preauth_tests.rs similarity index 100% rename from crates/collab-net/src/preauth_tests.rs rename to crates/collab-net/src/hub/preauth_tests.rs diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs new file mode 100644 index 000000000..b6bce39eb --- /dev/null +++ b/crates/collab-net/src/hub/queue.rs @@ -0,0 +1,210 @@ +use super::limits::ip_blocks; +use super::*; + +/// A message for one connection's writer: bytes (with what they count against, released when +/// they are sent or dropped), or the close. +pub(crate) enum Out { + Bytes(Bytes, Option), + Close(CloseCode, String), +} + +/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted +/// once against a shared counter (the server's, or a block's) until the last copy is sent or +/// dropped. +pub(super) struct Held { + n: usize, + counter: Arc, +} + +impl Held { + fn new(n: usize, counter: &Arc) -> Arc { + counter.fetch_add(n, Ordering::Relaxed); + Arc::new(Held { n, counter: counter.clone() }) + } +} + +impl Drop for Held { + fn drop(&mut self) { + self.counter.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// What one message counts against, made by its first recipient and shared by the others: the +/// server's budget once, and each recipient block's once (twenty people behind one office's +/// address cost their block one copy of a broadcast, not twenty). +#[derive(Default)] +pub(crate) struct Charges { + pub(super) all: Option>, + pub(super) blocks: Vec<(usize, Arc)>, + /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each + /// connection's cap, and a whole office joining at once must fit). + pub(crate) skip_blocks: bool, +} + +/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through +/// [`Held`], once against the server's budget and once against its block's). +pub(super) struct Charge { + n: usize, + mine: Arc, + _all: Arc, + _block: Option>, +} + +impl Drop for Charge { + fn drop(&mut self) { + self.mine.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// Why a message couldn't be queued. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Push { + /// The connection's or its block's queue is full: it can't keep up. + Full, + /// The server's budget for queued bytes is used up. + Everyone, + /// The connection is gone. + Gone, +} + +/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its +/// block of addresses', and the server's for everyone), so a client that doesn't read can't make +/// the server hold more than that for it. +#[derive(Clone)] +pub(crate) struct Line { + pub(crate) tx: SyncSender, + pub(crate) queued: Arc, + pub(crate) block: Arc, + pub(crate) all: Arc, + pub(crate) max: usize, + pub(crate) max_block: usize, + pub(crate) max_all: usize, +} + +impl Line { + /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file + /// gets out). `charges` is what the message counts against, shared by its recipients; + /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer + /// to make room). + pub(crate) fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { + let n = b.len(); + let mine = self.queued.load(Ordering::Relaxed); + if mine > 0 && mine.saturating_add(n) > self.max { + return Err(Push::Full); + } + let key = Arc::as_ptr(&self.block) as usize; + let block = if charges.skip_blocks { + None + } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { + Some(h.clone()) + } else { + let used = self.block.load(Ordering::Relaxed); + if used > 0 && used.saturating_add(n) > self.max_block { + return Err(Push::Full); + } + let h = Held::new(n, &self.block); + charges.blocks.push((key, h.clone())); + Some(h) + }; + let all = match &charges.all { + Some(h) => h.clone(), + None => { + if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { + return Err(Push::Everyone); + } + charges.all.insert(Held::new(n, &self.all)).clone() + } + }; + self.queued.fetch_add(n, Ordering::Relaxed); + let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; + self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { + TrySendError::Full(_) => Push::Full, + TrySendError::Disconnected(_) => Push::Gone, + }) + } +} + +/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is +/// having read, shared by its connections. +#[derive(Clone, Default)] +pub(crate) struct BlockUse { + pub(super) queued: Arc, + pub(super) reading: Arc, +} + +/// The blocks with open connections; a block leaves when its last connection ends. +#[derive(Clone, Default)] +pub(crate) struct Blocks(pub(crate) Arc>>); + +impl Blocks { + /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, + /// whose connections' queues and readers are all gone) are forgotten first, so the table + /// never holds more than the blocks of open connections. + pub(crate) fn get(&self, key: IpAddr) -> BlockUse { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); + t.entry(key).or_default().clone() + } + + /// Blocks remembered. + pub(crate) fn len(&self) -> usize { + self.0.lock().unwrap_or_else(PoisonError::into_inner).len() + } +} + +/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an +/// IPv6 address's /48. +pub fn wide_block(key: IpAddr) -> IpAddr { + ip_blocks(key).last().map_or(key, |(b, _)| b.0) +} + +/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader +/// drops its connection instead of piling more on a busy hub. +#[derive(Clone)] +pub(crate) struct InFlight(pub(crate) Arc); + +impl InFlight { + pub(super) fn take(&self, n: usize, max: usize) -> bool { + if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { + self.0.fetch_sub(n, Ordering::Relaxed); + return false; + } + true + } + + pub(super) fn give(&self, n: usize) { + self.0.fetch_sub(n, Ordering::Relaxed); + } +} + +pub(super) enum HubIn { + /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, + /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). + Open { + conn: u64, + ip: IpAddr, + first: ClientMsg, + out: Line, + sock: TcpStream, + /// Its size (in flight until handled). + bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, + }, + Msg { + conn: u64, + msg: ClientMsg, + bytes: usize, + }, + Gone { + conn: u64, + }, + Local(Presence), + LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + /// Cut every connection without a word (tests: a network failure). + #[cfg(test)] + Cut, + Stop, +} diff --git a/crates/collab-net/src/queue_tests.rs b/crates/collab-net/src/hub/queue_tests.rs similarity index 100% rename from crates/collab-net/src/queue_tests.rs rename to crates/collab-net/src/hub/queue_tests.rs diff --git a/crates/engine/src/assets.rs b/crates/engine/src/assets.rs new file mode 100644 index 000000000..e648ad3b8 --- /dev/null +++ b/crates/engine/src/assets.rs @@ -0,0 +1,7 @@ +pub mod components; +pub mod library; +pub mod library_server; +pub mod library_swap; +pub mod playground; +pub mod style_vars; +pub mod token_roles; diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs new file mode 100644 index 000000000..3932efffc --- /dev/null +++ b/crates/engine/src/assets/components.rs @@ -0,0 +1,301 @@ +//! 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; + +mod diff; +mod guard; +mod instances; +mod patch; +mod rebase; +mod sync; +mod variants; + +pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups}; +pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers}; +pub(crate) use instances::copy_group; +pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance}; +pub use patch::sync_after_edit; +#[cfg(test)] +pub(crate) use patch::{FULL_REBUILD, PATCHED}; +#[cfg(test)] +use rebase::RebaseSources; +pub use rebase::rebase_instance; +pub use sync::keep_text_overrides; +pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props}; + +/// Nesting limit for instances inside instances. +const MAX_DEPTH: usize = 16; +/// The override group (and `prop_refs` field) of slots. +pub const SLOT: &str = "slot"; +/// Every override group a layer can carry. +pub const OVERRIDE_GROUPS: &[&str] = &[ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "resizing", + "position", + "modes", + "component", + "props", + SLOT, +]; + +fn is_instance(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) +} + +/// A slot (inside an instance) whose children are the instance's own content. +fn holds_content(n: &Node) -> bool { + n.overrides.contains(SLOT) +} + +/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content: +/// content belongs to no instance (it edits freely, and instances placed in it stand alone). +fn scope(doc: &Document, id: NodeId) -> Vec { + let mut out = vec![id]; + for a in doc.ancestors(id) { + if doc.node(a).is_some_and(holds_content) { + break; + } + out.push(a); + } + out +} + +/// The component a node belongs to (itself, or the nearest component ancestor). +pub fn owning_component(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) +} + +/// The innermost instance containing `id` (`id` itself when it is an instance). +pub fn owning_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().find(|c| is_instance(doc, *c)) +} + +/// The outermost instance containing `id` (`id` itself when it is a top-level instance). +pub fn root_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c)) +} + +/// The innermost instance `id` is a layer of (never `id` itself). +fn enclosing_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c)) +} + +/// Whether `id` is a layer inside an instance (not the outermost instance itself). +pub fn inside_instance(doc: &Document, id: NodeId) -> bool { + enclosing_instance(doc, id).is_some() +} + +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + +/// The slot property frame `id` stands for (its name and preferred components): a frame bound to +/// a slot property of its component, or an instance's copy of one (followed through its sources). +pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { + let mut cur = id; + for _ in 0..=MAX_DEPTH { + let n = doc.node(cur)?; + if !matches!(n.kind, NodeKind::Frame) { + return None; + } + if let Some(name) = n.prop_refs.get(SLOT) { + let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None }; + return match props.get(name) { + Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())), + _ => None, + }; + } + cur = n.source?; + } + None +} + +/// The name of the slot property frame `id` stands for ([`slot_def`]). +pub fn slot_name(doc: &Document, id: NodeId) -> Option { + slot_def(doc, id).map(|(name, _)| name.to_string()) +} + +/// Whether `id` is a slot (in a main component, or an instance's copy of one). +pub fn is_slot(doc: &Document, id: NodeId) -> bool { + slot_def(doc, id).is_some() +} + +/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's +/// default content. +pub fn slot_edited(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(holds_content) +} + +/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and +/// reordered freely. +pub fn slot_scope(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + for c in chain { + if is_instance(doc, c) { + return None; + } + if is_slot(doc, c) { + return Some(c); + } + } + None +} + +/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an +/// instance, outside its slots. +pub fn structure_locked(doc: &Document, id: NodeId) -> bool { + owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none() +} + +/// For tree walks that look for where layers can go: whether the children of `id` are locked (in +/// an instance, outside its slots), given whether `id` itself is (`locked`). +pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool { + is_instance(doc, id) || (locked && !is_slot(doc, id)) +} + +/// Whether layer `id`'s place (parent and position among its siblings) changed between two +/// versions only where instance structure allows it. +pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool { + let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id))); + let (was, is) = (place(before), place(after)); + was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p))) +} + +/// The slots of a component or instance (its own: not those of instances nested in it, nor of the +/// content put into its slots), with their property names, top-most first. +pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 1_000_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + if let Some(name) = slot_name(doc, c) { + out.push((c, name)); + } + if !is_instance(doc, c) && !holds_content(n) { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} + +/// All instances of `component` in the document. +pub fn instances_of(doc: &Document, component: NodeId) -> Vec { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect() +} + +/// The component set a variant belongs to. +pub fn variant_set(doc: &Document, component: NodeId) -> Option { + doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +/// The layer name a fresh instance of `component` gets: the set's name for variants. +pub fn default_instance_name(doc: &Document, component: NodeId) -> String { + let set = variant_set(doc, component).and_then(|s| doc.node(s)); + set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into()) +} + +/// Default property values of a component (slots hold content, not values: they have none). +pub fn default_props(comp: &Node) -> BTreeMap { + let mut out = BTreeMap::new(); + if let NodeKind::Component { props, .. } = &comp.kind { + for (k, d) in props { + if takes_value(d) { + out.insert(k.clone(), prop_default_value(d)); + } + } + } + out +} + +/// Whether instances store a value for the property (every kind but slots). +pub fn takes_value(d: &PropDef) -> bool { + !matches!(d, PropDef::Slot { .. }) +} + +/// A property definition's default as a JSON value. +pub fn prop_default_value(d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => Value::Bool(*default), + PropDef::Text { default } => Value::String(default.clone()), + PropDef::InstanceSwap { default, .. } => Value::from(default.0), + PropDef::Variant { default, .. } => Value::String(default.clone()), + PropDef::Slot { .. } => Value::Null, + } +} + +#[cfg(test)] +mod rebase_source_tests; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs new file mode 100644 index 000000000..bf742fed1 --- /dev/null +++ b/crates/engine/src/assets/components/diff.rs @@ -0,0 +1,218 @@ +use super::instances::PROP_BINDING; +use super::*; + +/// The property groups whose value layer `n` takes from the same variables as its main layer +/// `src` (they differ only where the two resolve in different variable modes). +pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + for (k, v) in &n.bound { + let Some(g) = binding_group(k) else { continue }; + if src.bound.get(k) == Some(v) && !out.contains(&g) { + out.push(g); + } + } + out +} + +/// The override group a variable binding key feeds (`None` for component property and layout +/// grid bindings, which follow other rules). +pub(super) fn binding_group(key: &str) -> Option<&'static str> { + use crate::cmd::lists::{BoundList, list_binding}; + if key.starts_with(PROP_BINDING) { + return None; + } + match list_binding(key).map(|(l, _, _)| l) { + Some(BoundList::Fills) => Some("fills"), + Some(BoundList::Strokes) => Some("strokes"), + Some(BoundList::Effects) => Some("effects"), + Some(BoundList::Grids) => None, + None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), + } +} + +/// The variable bindings of `n` that feed group `g`. +pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { + n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) +} + +/// Property groups that differ between two versions of a node. +pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + if a.fills != b.fills { + out.push("fills"); + } + if a.strokes != b.strokes || a.stroke != b.stroke { + out.push("strokes"); + } + if a.effects != b.effects { + out.push("effects"); + } + if a.opacity != b.opacity || a.blend != b.blend { + out.push("opacity"); + } + if a.visible != b.visible { + out.push("visible"); + } + if a.name != b.name { + out.push("name"); + } + if a.corners != b.corners { + out.push("corners"); + } + if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { + out.push("size"); + } + if resizing(a) != resizing(b) { + out.push("resizing"); + } + if a.transform != b.transform { + out.push("position"); + } + if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { + if x.characters != y.characters { + out.push("characters"); + } + if text_style_differs(x, y) { + out.push("textStyle"); + } + } + if let (Some(x), Some(y)) = (&a.layout, &b.layout) { + if x.padding != y.padding { + out.push("padding"); + } + if x.gap != y.gap || x.cross_gap != y.cross_gap { + out.push("gap"); + } + if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { + out.push("alignment"); + } + } + if a.grids != b.grids || a.styles.grid != b.styles.grid { + out.push("grids"); + } + if a.exports != b.exports { + out.push("exports"); + } + if a.interactions != b.interactions { + out.push("interactions"); + } + if a.var_modes != b.var_modes { + out.push("modes"); + } + if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { + if x != y { + out.push("component"); + } + if px != py { + out.push("props"); + } + } + out +} + +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + +/// Whether two texts differ in anything but their characters (the `textStyle` group). +fn text_style_differs(x: &TextData, y: &TextData) -> bool { + x.style != y.style + || x.runs != y.runs + || x.auto_resize != y.auto_resize + || x.align_h != y.align_h + || x.align_v != y.align_v + || x.paragraph_spacing != y.paragraph_spacing + || x.paragraph_indent != y.paragraph_indent + || x.list != y.list + || x.paragraphs != y.paragraphs + || x.list_spacing != y.list_spacing + || x.hanging_punctuation != y.hanging_punctuation + || x.hanging_list != y.hanging_list + || x.max_lines != y.max_lines + || x.features != y.features +} + +/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was +/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. +pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { + for a in doc.ancestors(id) { + let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; + if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { + return true; + } + } + let Some(n) = doc.node(id) else { return true }; + if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { + return true; + } + if group == "size" + && let NodeKind::Text(t) = &n.kind + { + let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); + return match t.auto_resize { + TextAutoResize::WidthAndHeight => true, + TextAutoResize::Height => !wchanged, + _ => false, + }; + } + false +} + +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs new file mode 100644 index 000000000..b320b7c4e --- /dev/null +++ b/crates/engine/src/assets/components/guard.rs @@ -0,0 +1,135 @@ +use super::diff::geometry_derived; +use super::rebase::rebase_instance; +use super::variants::instance_issues; +use super::*; + +/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the +/// main's default content until now) stop mirroring the main, and instances among them stand +/// alone, linked to their own mains. +pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) { + if let Some(n) = doc.node_mut(slot) { + n.overrides.insert(SLOT.into()); + } + for nested in unlink_layers(doc, slot, None) { + rebase_instance(doc, nested); + } +} + +/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the +/// content of a slot): plain layers forget the main layer they mirrored and their property +/// bindings. The instances among them are returned, to re-link to their own mains (with +/// [`rebase_instance`]) once the caller is done. +pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { + let picked: Vec<(NodeId, bool)> = + doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); + let mut nested = Vec::new(); + for (d, instance) in picked { + let Some(n) = doc.node_mut(d) else { continue }; + n.prop_refs.clear(); + if instance { + nested.push(d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nested +} + +/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it +/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). +pub(super) fn relink_copy(doc: &mut Document, id: NodeId) { + let Some(n) = doc.node(id) else { return }; + match &n.kind { + NodeKind::Instance { component, .. } => { + let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); + if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { + rebase_instance(doc, id); + } + } + _ => { + if (n.source.is_some() || !n.overrides.is_empty()) + && owning_instance(doc, id).is_none() + && let Some(m) = doc.node_mut(id) + { + m.source = None; + m.overrides.clear(); + } + } + } +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside +/// its slots. +pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { + for &id in after.dirty() { + let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; + // Library copies change only through library updates (and are read-only otherwise). + if n.children == b.children || after.is_remote(id) { + continue; + } + if structure_locked(before, id) && structure_locked(after, id) { + let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); + return Err(format!( + "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" + )); + } + } + Ok(()) +} + +/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled +/// back. +pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { + match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { + Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), + None => Ok(()), + } +} + +/// What about layer `id` an edit from `before` to `after` changed that only its main can: the +/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). +pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { + let (n, b) = (after.node(id)?, before.node(id)?); + if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { + return None; + } + let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); + let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); + // Auto layout settings other than the overridable ones. + let settings = |x: &Node| { + x.layout.clone().map(|mut l| { + (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l.content_align = ContentAlign::Auto; + l + }) + }; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; + if mode(n) != mode(b) && mode(ma) == mode(mb) { + return Some("The auto layout direction"); + } + if settings(n) != settings(b) && settings(ma) == settings(mb) { + return Some("This auto layout setting"); + } + if !inside_instance(after, id) || !inside_instance(before, id) { + return None; + } + if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { + return Some("A layer's position"); + } + if n.constraints != b.constraints && ma.constraints == mb.constraints { + return Some("A layer's constraints"); + } + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); + } + None +} diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs new file mode 100644 index 000000000..9fd4e2c20 --- /dev/null +++ b/crates/engine/src/assets/components/instances.rs @@ -0,0 +1,342 @@ +use super::diff::{binding_group, bindings_of}; +use super::sync::{insert_linked, sync_instance_depth}; +use super::*; + +/// Create an instance of `component` under `parent` at `(x, y)` (parent space). +pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { + create_instance_at(doc, component, parent, x, y, None) +} + +/// [`create_instance`] at child `index` of `parent` (None: on top). +pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result { + let comp = doc.get(component)?.clone(); + if !matches!(comp.kind, NodeKind::Component { .. }) { + return Err(DocError::Invalid(format!("{component} is not a component"))); + } + if doc.is_ancestor(component, parent) { + return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + } + let id = doc.alloc_id(); + let mut root = comp.clone(); + root.id = id; + root.name = default_instance_name(doc, component); + root.kind = NodeKind::Instance { component, props: default_props(&comp) }; + root.children.clear(); + root.parent = None; + root.source = Some(component); + root.overrides.clear(); + root.transform = Affine::translate((x, y)); + root.prop_refs.clear(); + root.annotations.clear(); + root.code_links.clear(); + // The main's dev resources show on its instances without being copied. + root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); + // The main's interactions play on its instances in prototypes; the instance's own start empty. + root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); + doc.insert(root, parent, index)?; + sync_instance(doc, id); + Ok(id) +} + +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + +/// Which part of the carried overrides to apply. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Carry { + /// Paint, text, visibility, … (everything but geometry). + Values, + /// Values plus size (instance roots: their size decides how children follow constraints). + Root, + /// Size and position (children, after constraints ran). + Geometry, +} + +/// Copy the overridden property groups of `from` onto `to`. +pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { + for g in &from.overrides { + let geo = matches!(g.as_str(), "size" | "position"); + match what { + Carry::Values if geo => continue, + Carry::Root if g == "position" => continue, + Carry::Geometry if !geo => continue, + _ => {} + } + copy_group(from, to, g); + } + if what != Carry::Geometry { + to.overrides = from.overrides.clone(); + } +} + +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + +/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. +pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { + if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { + to.bound.retain(|k, _| binding_group(k) != Some(g)); + to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); + } + match g { + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } + "strokes" => { + to.strokes = from.strokes.clone(); + to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); + } + "opacity" => { + to.opacity = from.opacity; + to.blend = from.blend; + } + "visible" => to.visible = from.visible, + "name" => to.name = from.name.clone(), + "corners" => to.corners = from.corners, + "characters" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.characters = a.characters.clone(); + b.runs = a.runs.clone(); + b.paragraphs = a.paragraphs.clone(); + } + } + "textStyle" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.style = a.style.clone(); + b.runs = a.runs.clone(); + b.auto_resize = a.auto_resize; + b.align_h = a.align_h; + b.align_v = a.align_v; + b.paragraph_spacing = a.paragraph_spacing; + b.paragraph_indent = a.paragraph_indent; + b.list = a.list; + b.paragraphs = a.paragraphs.clone(); + b.list_spacing = a.list_spacing; + b.hanging_punctuation = a.hanging_punctuation; + b.hanging_list = a.hanging_list; + b.max_lines = a.max_lines; + b.features = a.features.clone(); + } + to.styles.text = from.styles.text.clone(); + } + "padding" | "gap" | "alignment" => { + if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { + match g { + "padding" => b.padding = a.padding, + "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), + _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), + } + } + } + "grids" => { + to.grids = from.grids.clone(); + to.styles.grid = from.styles.grid.clone(); + } + "exports" => to.exports = from.exports.clone(), + "interactions" => to.interactions = from.interactions.clone(), + "size" => { + to.width = from.width; + to.height = from.height; + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } + } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } + "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), + "component" => { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { + *b = *a; + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); + } + carry_prop_bindings(from, to); + } + _ => {} + } +} + +/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With +/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames +/// don't break matching), and the content of slots holding the instance's own is left out. +pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![(root, String::new())]; + while let Some((id, prefix)) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + // A nested instance linked only to its own component so far (an imported one) mirrors no + // layer yet: it goes by its own name, like the main's layer it will be matched with. + let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); + let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); + let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); + let k = seen.entry(name).or_insert(0); + let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; + *k += 1; + let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; + out.insert(*c, path.clone()); + if out.len() > 1_000_000 { + return out; + } + if !(src_names && holds_content(cn)) { + stack.push((*c, path)); + } + } + } + out +} + +/// The binding key prefix of an instance's component property value +/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a +/// string variable. +pub const PROP_BINDING: &str = "componentProperties/"; + +/// Give `to` the component property bindings of instance `from` (they go with its property +/// values, which an instance keeps across rebuilds). +pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) { + to.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone()))); +} + +/// The override group a property-bound field belongs to. +pub(super) fn field_group(field: &str) -> &'static str { + match field { + "visible" => "visible", + "characters" => "characters", + "mainComponent" => "component", + _ => "", + } +} + +/// Apply one component-property value to a layer. +pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { + match field { + "visible" => { + if let Some(b) = val.as_bool() { + n.visible = b; + } + } + "characters" => { + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) + && t.characters != sv + { + let len = t.char_count(); + t.replace_range(0, len, &sv); + } + } + "mainComponent" => { + if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) + && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) + { + *c = v; + } + } + _ => {} + } +} + +/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its +/// slots. An instance whose main component was deleted keeps its layers as they are. +pub fn sync_instance(doc: &mut Document, inst: NodeId) { + // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). + let old: Vec<(std::sync::Arc, bool)> = + doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); + let kept = slot_content(doc, inst); + sync_instance_depth(doc, inst, 0, BTreeMap::new()); + restore_slot_content(doc, kept); + for (a, was_dirty) in old { + doc.keep_if_unchanged(a, was_dirty); + } +} + +/// The slots in instance `inst` (nested instances included) holding the instance's own content: +/// each with its content (the subtrees of its children, pre-order). +fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); + while let Some(c) = stack.pop() { + let Some(n) = doc.node(c) else { continue }; + if holds_content(n) { + let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); + out.push((c, nodes)); + } else { + stack.extend(n.children.iter().copied()); + } + } + out +} + +/// Put slot content back after a rebuild, into the slots that still hold the instance's own +/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints +/// and geometry overrides ran before, so the content keeps its place. +fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { + for (slot, nodes) in kept { + let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); + if !keep { + continue; + } + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + for n in nodes { + // Pre-order: every parent is back before its children. + let _ = insert_linked(doc, n); + } + // Never leave links to a layer that couldn't come back. + for id in ids.into_iter().chain([slot]) { + let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); + if dangling { + let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); + if let Some(n) = doc.node_mut(id) { + n.children = live; + } + } + } + } +} diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs new file mode 100644 index 000000000..8c61f0b8a --- /dev/null +++ b/crates/engine/src/assets/components/patch.rs @@ -0,0 +1,528 @@ +use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups}; +use super::guard::{own_slot_content, relink_copy}; +use super::instances::{copy_group, field_group, sync_instance}; +use super::sync::insert_linked; +use super::*; + +/// 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); + } + } +} diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs new file mode 100644 index 000000000..748a99d33 --- /dev/null +++ b/crates/engine/src/assets/components/rebase.rs @@ -0,0 +1,249 @@ +use super::diff::{diff_groups, variable_groups}; +use super::instances::{field_group, path_map}; +use super::*; + +/// Bounded source and parent lookups while a rebase reads an unchanged document. +pub(super) struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + pub(super) fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + +/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing +/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): +/// the look is kept exactly. Values a component property sets aren't overrides. +pub fn rebase_instance(doc: &mut Document, inst: NodeId) { + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; + let mine = path_map(doc, inst, true); + let theirs: BTreeMap = + if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; + let value_groups = |a: &Node, b: &Node| -> Vec { + diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() + }; + let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); + for (id, path) in &mine { + let Some(n) = doc.node(*id) else { continue }; + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; + } + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); + let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); + if let Some(m) = source.and_then(|source| doc.node(source)) { + let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + bound.extend(variable_groups(n, m)); + overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); + if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { + overrides.insert("component".into()); + } + } + updates.push((*id, source, overrides)); + } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } + if let Some(n) = doc.node_mut(inst) { + n.source = root_base; + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); + n.overrides.extend(root_groups); + } +} diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/assets/components/rebase_source_tests.rs similarity index 100% rename from crates/engine/src/components_rebase_source_tests.rs rename to crates/engine/src/assets/components/rebase_source_tests.rs diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs new file mode 100644 index 000000000..c502322d7 --- /dev/null +++ b/crates/engine/src/assets/components/sync.rs @@ -0,0 +1,459 @@ +use super::diff::diff_groups; +use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; +use super::*; + +/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested +/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. +pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { + if depth > MAX_DEPTH { + return; + } + let Some(mut root) = doc.node(inst).cloned() else { return }; + let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; + let component = *component; + let Some(comp) = doc.node(component).cloned() else { return }; + if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { + // A missing main, or an instance inside its own main (would recurse forever). + return; + } + let 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). +pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { + let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; + let id = n.id; + let mut n = n; + let kids = std::mem::take(&mut n.children); + doc.insert(n, parent, None)?; + if let Some(m) = doc.node_mut(id) { + m.children = kids; + } + // The parent may already list us (its children were copied with ids): keep the first. + if let Some(p) = doc.node_mut(parent) { + let mut seen = BTreeSet::new(); + p.children.retain(|c| seen.insert(*c)); + } + Ok(()) +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/assets/components/tests.rs similarity index 100% rename from crates/engine/src/components_tests.rs rename to crates/engine/src/assets/components/tests.rs diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs new file mode 100644 index 000000000..40ac23eac --- /dev/null +++ b/crates/engine/src/assets/components/variants.rs @@ -0,0 +1,147 @@ +use super::*; + +/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then +/// the left-most). +pub fn default_variant(doc: &Document, set: NodeId) -> Option { + let corner = |c: &NodeId| { + doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) + }; + let kids = &doc.node(set)?.children; + kids.iter() + .copied() + .filter_map(|c| corner(&c).map(|k| (k, c))) + .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(_, c)| c) +} + +/// Variants of a set sharing one combination of property values (each group two or more, in +/// layer order): a conflict to resolve by giving each a unique combination. +pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { + let mut by: BTreeMap, Vec> = BTreeMap::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { + by.entry(variant_props(&n.name)).or_default().push(c); + } + } + by.into_values().filter(|v| v.len() > 1).collect() +} + +/// Parse a variant name "Size=Large, State=Hover" into pairs. +pub fn variant_props(name: &str) -> BTreeMap { + name.split(',') + .filter_map(|kv| { + let (k, v) = kv.split_once('=')?; + Some((k.trim().to_string(), v.trim().to_string())) + }) + .collect() +} + +/// Format variant pairs as a layer name, in the given property order (others after, sorted). +pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { + let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); + keys.extend(pairs.keys().filter(|k| !order.contains(k))); + keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") +} + +/// Variant properties of a set: property → values, in first-seen order. +pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + for kv in cn.name.split(',') { + let Some((k, v)) = kv.split_once('=') else { continue }; + let (k, v) = (k.trim().to_string(), v.trim().to_string()); + match out.iter_mut().find(|(p, _)| *p == k) { + Some((_, vals)) => { + if !vals.contains(&v) { + vals.push(v); + } + } + None => out.push((k, vec![v])), + } + } + } + out +} + +/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. +pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { + let s = doc.node(set)?; + let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); + if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { + return Some(exact); + } + s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) +} + +/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each +/// layer's `source` is the main layer at the same place, swapped nested instances mirror their +/// own main, slots holding the instance's own content aren't compared. Returns the problems found. +pub fn mirror_issues(doc: &Document) -> Vec { + doc.nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) + .flat_map(|n| instance_issues(doc, n.id)) + .collect() +} + +/// [`mirror_issues`] for one instance. +pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec { + fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH || holds_content(inst) { + return; + } + if inst.children.len() != main.children.len() { + out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); + return; + } + for (a, b) in inst.children.iter().zip(&main.children) { + let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; + if an.source != Some(bn.id) { + out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); + continue; + } + match (&an.kind, &bn.kind) { + (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { + if let Some(m) = doc.node(*x) { + walk(doc, an, m, depth + 1, out); + } + } + _ => walk(doc, an, bn, depth + 1, out), + } + } + } + let mut out = Vec::new(); + if let Some(n) = doc.node(id) + && let NodeKind::Instance { component, .. } = &n.kind + && let Some(m) = doc.node(*component) + { + walk(doc, n, m, 0, &mut out); + } + out +} + +/// The nested instance copies inside instance `id` whose main-component counterpart is exposed +/// (top-most first; an exposed instance's own exposed nested instances follow it). +pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 100_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + // A hidden exposed instance hides its properties too. + let exposed = + n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + if exposed { + out.push(c); + } + // Only exposed nested instances pass their own exposed instances up. + if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/assets/library.rs similarity index 99% rename from crates/engine/src/library.rs rename to crates/engine/src/assets/library.rs index 40d6b3cad..56b0fc07a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/assets/library.rs @@ -1331,5 +1331,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator Re } #[cfg(test)] -#[path = "library_swap_tests.rs"] mod tests; diff --git a/crates/engine/src/library_swap_tests.rs b/crates/engine/src/assets/library_swap/tests.rs similarity index 100% rename from crates/engine/src/library_swap_tests.rs rename to crates/engine/src/assets/library_swap/tests.rs diff --git a/crates/engine/src/playground.rs b/crates/engine/src/assets/playground.rs similarity index 100% rename from crates/engine/src/playground.rs rename to crates/engine/src/assets/playground.rs diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/assets/style_vars.rs similarity index 100% rename from crates/engine/src/style_vars.rs rename to crates/engine/src/assets/style_vars.rs diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/assets/token_roles.rs similarity index 100% rename from crates/engine/src/token_roles.rs rename to crates/engine/src/assets/token_roles.rs diff --git a/crates/engine/src/automation.rs b/crates/engine/src/automation.rs new file mode 100644 index 000000000..acf11e5c7 --- /dev/null +++ b/crates/engine/src/automation.rs @@ -0,0 +1,5 @@ +pub mod batch; +pub mod doc_urls; +pub mod file_access; +pub mod inspect; +pub mod params; diff --git a/crates/engine/src/batch.rs b/crates/engine/src/automation/batch.rs similarity index 100% rename from crates/engine/src/batch.rs rename to crates/engine/src/automation/batch.rs diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/automation/doc_urls.rs similarity index 100% rename from crates/engine/src/doc_urls.rs rename to crates/engine/src/automation/doc_urls.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/automation/file_access.rs similarity index 99% rename from crates/engine/src/file_access.rs rename to crates/engine/src/automation/file_access.rs index 7a8ab8c92..f741e21c9 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -306,5 +306,4 @@ pub fn create_dir_all(path: impl AsRef) -> Result<()> { } #[cfg(test)] -#[path = "file_access_tests.rs"] mod tests; diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/automation/file_access/tests.rs similarity index 100% rename from crates/engine/src/file_access_tests.rs rename to crates/engine/src/automation/file_access/tests.rs diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/automation/inspect.rs similarity index 100% rename from crates/engine/src/inspect.rs rename to crates/engine/src/automation/inspect.rs diff --git a/crates/engine/src/params.rs b/crates/engine/src/automation/params.rs similarity index 100% rename from crates/engine/src/params.rs rename to crates/engine/src/automation/params.rs diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs deleted file mode 100644 index 5408c6c94..000000000 --- a/crates/engine/src/board.rs +++ /dev/null @@ -1,1495 +0,0 @@ -//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and -//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and -//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, -//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and -//! live-session edits from others all end in the same geometry. - -use std::collections::{BTreeMap, BTreeSet}; - -use figcraft_color::{BlendMode, Color, Paint}; -use figcraft_doc::{ - AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, - HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, - TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, -}; -use figcraft_geom::kurbo::PathEl; -use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; -use serde_json::{Value, json}; - -use crate::cmd::CommandSpec; -use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; -use crate::tools::{Gesture, PointerEvent, ToolId}; -use crate::{EngineError, Result, Session, bad, spec, textedit}; - -/// Why a board command refuses on a design file. -pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; -/// Why prototyping and Dev Mode refuse on a board. -pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; - -/// A sticky note's side (it grows taller for long notes). -pub const STICKY: f64 = 200.0; -const STICKY_PAD: f64 = 16.0; -/// The room between a sticky and the next one made beside it. -const STICKY_GAP: f64 = 24.0; -/// A new shape's size. -const SHAPE: f64 = 160.0; -/// Text on board objects, and the author line under a sticky's note. -pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); -const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); -/// Connectors' default colour and weight. -const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); -const LINE_WEIGHT: f64 = 2.0; -/// Marker and highlighter strokes: (weight, opacity). -const MARKER: (f64, f32) = (4.0, 1.0); -const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); -/// How far the eraser reaches around the pointer (screen pixels). -const ERASER_PX: f64 = 8.0; -/// The text sizes the contextual bar offers. -pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; - -/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, -/// how new connectors run and the marker's and highlighter's colours. -#[derive(Clone, Debug)] -pub struct BoardTool { - pub sticky: Color, - pub shape: BoardShape, - pub stamp: Stamp, - pub routing: Routing, - pub marker: Color, - pub highlighter: Color, -} - -impl Default for BoardTool { - fn default() -> Self { - let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); - Self { - sticky: first, - shape: BoardShape::Rectangle, - stamp: Stamp::ThumbsUp, - routing: Routing::Elbow, - marker: INK, - highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), - } - } -} - -pub fn specs() -> Vec { - vec![ - spec!( - "board.sticky", - "Sticky note", - "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", - true, - sticky_cmd - ), - spec!( - "board.nextSticky", - "Next sticky note", - "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", - true, - next_sticky - ), - spec!( - "board.shape", - "Shape with text", - "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", - true, - shape_cmd - ), - spec!( - "board.connector", - "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", - true, - connector_cmd - ), - spec!( - "board.reattach", - "Reattach connector", - "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", - true, - reattach - ), - spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), - spec!( - "board.stamp", - "Stamp", - "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", - true, - stamp_cmd - ), - spec!( - "board.ink", - "Marker stroke", - "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", - true, - ink_cmd - ), - spec!( - "board.setColor", - "Board color", - "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", - true, - set_color - ), - spec!( - "board.setStyle", - "Board style", - "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", - true, - set_style - ), - spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { - if let Some(c) = color_param("tool.sticky", p)? { - s.tool.board.sticky = c; - } - board_tool(s, ToolId::Sticky) - }), - spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { - if let Some(v) = string(p, "shape") { - s.tool.board.shape = shape_of("tool.shape", &v)?; - } - board_tool(s, ToolId::BoardShape) - }), - spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { - if let Some(v) = string(p, "style") { - s.tool.board.routing = routing_of("tool.connector", &v)?; - } - board_tool(s, ToolId::Connector) - }), - spec!("tool.marker", "Marker", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.marker", p)? { - s.tool.board.marker = c; - } - board_tool(s, ToolId::Marker) - }), - spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.highlighter", p)? { - s.tool.board.highlighter = c; - } - board_tool(s, ToolId::Highlighter) - }), - spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), - spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { - if let Some(v) = string(p, "stamp") { - s.tool.board.stamp = stamp_of("tool.stamp", &v)?; - } - board_tool(s, ToolId::Stamp) - }), - ] -} - -pub fn is_board(doc: &Document) -> bool { - doc.kind == FileKind::Board -} - -/// Refuse `cmd` unless the active file is a board. -pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { - if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } -} - -/// Prototyping and Dev Mode: not on boards. -fn design_only(cmd: &str) -> bool { - cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") -} - -/// Refuse `cmd` on a board when it is prototyping or Dev Mode. -pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { - if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } -} - -fn board_tool(s: &mut Session, t: ToolId) -> Result { - require(s, &format!("tool.{}", t.id()))?; - crate::cmd::view::tool(s, t) -} - -fn shape_of(cmd: &str, v: &str) -> Result { - BoardShape::from_id(v) - .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) -} - -fn stamp_of(cmd: &str, v: &str) -> Result { - Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) -} - -fn routing_of(cmd: &str, v: &str) -> Result { - Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) -} - -fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { - match get(p, key) { - None | Some(Value::Null) => Ok(None), - Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), - } -} - -/// `color`: a board colour's name, or any colour. -fn color_param(cmd: &str, p: &Value) -> Result> { - match get(p, "color") { - None | Some(Value::Null) => Ok(None), - Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), - Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), - } -} - -/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). -fn line_shade(c: Color) -> Color { - let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); - if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } -} - -/// Your name on what you make: the name from Preferences, else the one a live session gave you -/// (none for the default name outside a session). -pub(crate) fn author(s: &Session) -> String { - let name = crate::live::display_name(&s.prefs); - if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } -} - -/// The parent a command places into, and that parent's page-to-local transform. -fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { - let page = s.page()?; - let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); - let doc = s.doc()?; - let n = doc.get(parent)?; - let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); - if !takes { - return Err(bad(cmd, "`parent` is the page, a section or a frame")); - } - Ok(parent) -} - -/// A new layer at the top of `parent`, at `at` in it, made by `f`. -fn add( - doc: &mut Document, - parent: NodeId, - kind: NodeKind, - name: &str, - at: (f64, f64), - size: (f64, f64), - f: impl FnOnce(&mut Node), -) -> Result { - let id = doc.alloc_id(); - let mut n = Node::new(id, kind, name, size.0, size.1); - n.transform = Affine::translate(at); - f(&mut n); - doc.insert(n, parent, None)?; - Ok(id) -} - -/// A soft shadow under stickies and stamps. -fn soft_shadow() -> Effect { - Effect { - kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), - visible: true, - } -} - -/// A text layer filling its frame's width (the frame's auto layout places it). -pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { - let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); - let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; - let mut td = TextData { - characters: crate::cmd::props::normalize_line_endings(text), - auto_resize: TextAutoResize::Height, - align_h: align, - ..TextData::default() - }; - td.style.font_size = size; - let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { - n.fills = vec![Paint::solid(color)]; - n.child_layout.sizing_h = Sizing::Fill; - })?; - if let Some(n) = doc.node(id) - && let Some((w, h)) = figcraft_text::auto_size(n) - && let Some(n) = doc.node_mut(id) - { - (n.width, n.height) = (w, h); - } - Ok(id) -} - -/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so -/// every copy of the file picks the same size whatever fonts it has). -pub fn sticky_font_size(text: &str) -> f64 { - let weight = text.chars().count() + 24 * text.matches('\n').count(); - match weight { - 0..=24 => 24.0, - 25..=64 => 20.0, - 65..=140 => 16.0, - 141..=280 => 13.0, - _ => 11.0, - } -} - -/// A sticky note's layers: the frame, the note and (when known) its author. -fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { - let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { - n.fills = vec![Paint::solid(color)]; - n.corners.radii = [2.0; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - gap: 8.0, - padding: [STICKY_PAD; 4], - main_align: MainAlign::SpaceBetween, - ..AutoLayout::default() - }); - n.child_layout.sizing_v = Sizing::Hug; - n.child_layout.min_h = Some(STICKY); - n.board = Some(BoardItem::Sticky { author: author.to_string() }); - })?; - let inner = STICKY - 2.0 * STICKY_PAD; - let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; - if !author.is_empty() { - let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; - if let Some(n) = doc.node_mut(a) { - n.name = "Author".into(); - } - } - Ok((id, note)) -} - -fn sticky_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.sticky"; - require(s, cmd)?; - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let author = match get(p, "author") { - Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), - Some(Value::Bool(false)) => String::new(), - _ => author(s), - }; - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": note})) -} - -/// The first text layer inside board object `id` (its note or label). -pub fn text_of(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - n.board.as_ref().filter(|b| b.has_text())?; - n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) -} - -/// The board object whose text `text` is, if any. -pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { - let p = doc.node(text)?.parent?; - (text_of(doc, p) == Some(text)).then_some(p) -} - -/// Start typing in board object `id`'s text (all of it selected). False when it has none. -pub fn edit_text(s: &mut Session, id: NodeId) -> Result { - let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; - textedit::begin(s, t, None, false)?; - textedit::key(s, "SelectAll", false, false, false)?; - Ok(true) -} - -fn next_sticky(s: &mut Session, p: &Value) -> Result { - let cmd = "board.nextSticky"; - require(s, cmd)?; - let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); - let doc = s.doc()?; - let from = id(p, "id") - .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) - .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; - let n = doc.get(from)?; - if !matches!(n.board, Some(BoardItem::Sticky { .. })) { - return Err(bad(cmd, "that isn't a sticky")); - } - let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); - let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; - let at = n.transform.translation(); - let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { - "right" => (n.width + STICKY_GAP, 0.0), - "left" => (-(STICKY + STICKY_GAP), 0.0), - "below" => (0.0, n.height + STICKY_GAP), - "above" => (0.0, -(STICKY + STICKY_GAP)), - other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), - }; - let author = author(s); - if editing.is_some() { - textedit::end(s)?; - } - let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; - s.set_selection(vec![id]); - if boolean(p, "edit") == Some(true) { - // Typing goes on in the new sticky once this step is recorded. - s.request_ui("board.editText", json!({"id": id})); - } - Ok(json!({"id": id, "text": note})) -} - -/// A shape with text's layers: the frame, its outline and its text. -fn make_shape( - doc: &mut Document, - parent: NodeId, - at: (f64, f64), - size: (f64, f64), - shape: BoardShape, - text: &str, - color: Color, -) -> Result<(NodeId, NodeId)> { - let (w, h) = size; - let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { - n.fills.clear(); - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - padding: shape_padding(shape, w, h), - main_align: MainAlign::Center, - cross_align: CrossAlign::Center, - ..AutoLayout::default() - }); - n.board = Some(BoardItem::Shape { shape }); - })?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - n.stroke.weight = 1.5; - n.stroke.align = StrokeAlign::Inside; - n.child_layout.absolute = true; - n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; - })?; - let [_, r, _, l] = shape_padding(shape, w, h); - let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; - Ok((id, label)) -} - -fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { - let [t, r, b, l] = shape.text_insets(); - [t * h, r * w, b * h, l * w] -} - -fn shape_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.shape"; - require(s, cmd)?; - let shape = match string(p, "shape") { - Some(v) => shape_of(cmd, &v)?, - None => s.tool.board.shape, - }; - let size = |k| { - num(p, k) - .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) - }; - let (w, h) = (size("width")?, size("height")?); - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": label})) -} - -fn stamp_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.stamp"; - require(s, cmd)?; - let stamp = match string(p, "stamp") { - Some(v) => stamp_of(cmd, &v)?, - None => s.tool.board.stamp, - }; - let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let author = author(s); - let page = s.page()?; - let half = STAMP_SIZE / 2.0; - let doc = s.doc_mut()?; - let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { - n.fills = vec![Paint::solid(stamp.color())]; - n.corners.radii = [half; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.board = Some(BoardItem::Stamp { stamp, author }); - })?; - for part in stamp.mark() { - let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { - if part.filled { - n.fills = vec![Paint::solid(Color::WHITE)]; - } else { - n.fills.clear(); - n.strokes = vec![Paint::solid(Color::WHITE)]; - n.stroke.weight = 3.0; - (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); - n.stroke.join = StrokeJoin::Round; - n.stroke.align = StrokeAlign::Center; - } - })?; - } - s.set_selection(vec![id]); - Ok(json!({"id": id})) -} - -fn ink_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.ink"; - require(s, cmd)?; - let high = boolean(p, "highlighter").unwrap_or(false); - let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); - let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; - if get(p, "path").is_none() && get(p, "points").is_none() { - return Err(bad(cmd, "`path` or `points` is required")); - } - let page = s.page()?; - let select = boolean(p, "select").unwrap_or(true); - let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { - "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", - }}); - for k in ["path", "points"] { - if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { - o.insert(k.into(), v.clone()); - } - } - let made = crate::cmd::create::create(s, &q)?; - let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; - let n = s.doc_mut()?.get_mut(id)?; - n.opacity = opacity; - if high { - n.blend = BlendMode::Multiply; - } - n.board = Some(BoardItem::Ink); - Ok(json!({"id": id})) -} - -/// One connector end from the params: a layer on this page, or a point. -fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { - let side = match string(p, side_key) { - Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, - None => Side::Auto, - }; - match get(p, key) { - Some(Value::Object(o)) if o.contains_key("x") => { - let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); - match (x, y) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), - _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), - } - } - Some(v) => { - let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; - let doc = s.doc()?; - let n = doc.get(node)?; - if n.is_page() || doc.page_of(node) != Some(s.page()?) { - return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); - } - if matches!(n.board, Some(BoardItem::Connector(_))) { - return Err(bad(cmd, format!("`{key}` can't be another connector"))); - } - Ok(End::Node { node, side }) - } - None => Err(bad(cmd, format!("`{key}` is required"))), - } -} - -fn connector_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.connector"; - require(s, cmd)?; - let from = end_param(cmd, s, p, "from", "fromSide")?; - let to = end_param(cmd, s, p, "to", "toSide")?; - if from.node().is_some() && from.node() == to.node() { - return Err(bad(cmd, "connect two different layers")); - } - let routing = match string(p, "style") { - Some(v) => routing_of(cmd, &v)?, - None => s.tool.board.routing, - }; - let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); - let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); - let c = Connector { from, to, routing }; - let page = s.page()?; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { - n.fills.clear(); - n.strokes = vec![Paint::solid(LINE)]; - n.stroke.weight = LINE_WEIGHT; - n.stroke.join = StrokeJoin::Round; - n.stroke.cap_start = start; - n.stroke.cap_end = end; - n.board = Some(BoardItem::Connector(c)); - })?; - place_route(doc, id, &pts, routing); - let mut out = json!({"id": id}); - if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { - out["label"] = json!(set_label(doc, id, &text)?); - } - s.set_selection(vec![id]); - Ok(out) -} - -/// The connector `id` names: a connector, or the label on one. -fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { - match doc.get(id)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((id, c)), - Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((connector, c)), - _ => Err(bad(cmd, "that isn't a connector")), - }, - _ => Err(bad(cmd, "that isn't a connector")), - } -} - -fn reattach(s: &mut Session, p: &Value) -> Result { - let cmd = "board.reattach"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, mut c) = connector_of(cmd, s.doc()?, id)?; - let from = match string(p, "end").as_deref() { - Some("from") => true, - Some("to") => false, - _ => return Err(bad(cmd, "`end` is from or to")), - }; - let end = end_param(cmd, s, p, "target", "side")?; - let other = if from { c.to } else { c.from }; - if end.node().is_some() && end.node() == other.node() { - return Err(bad(cmd, "connect two different layers")); - } - *(if from { &mut c.from } else { &mut c.to }) = end; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); - place_route(doc, id, &pts, c.routing); - Ok(json!({"id": id})) -} - -/// A connector end as `board.connector` and `board.reattach` take it. -fn end_json(e: End) -> Value { - match e { - End::Node { node, .. } => json!(node), - End::Point { x, y } => json!({"x": x, "y": y}), - } -} - -/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. -fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { - match &doc.node(id)?.board { - Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), - _ => None, - } -} - -/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. -pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { - let [id] = sel else { return None }; - if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { - return None; - } - let (a, b) = ends_now(doc, *id)?; - Some((*id, [a, b])) -} - -/// How near (screen pixels) a press must be to a selected connector's end to drag it. -pub const END_HANDLE_PX: f64 = 8.0; - -/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). -pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { - let (id, [a, b]) = selected_connector_ends(doc, sel)?; - let (da, db) = ((a - p).hypot(), (b - p).hypot()); - (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) -} - -/// The label of connector `id`, if it has one. -pub fn label_of(doc: &Document, id: NodeId) -> Option { - let parent = doc.node(id)?.parent?; - doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) -} - -/// Give connector `id` the label `text` (empty removes it); the label's id. -fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { - let existing = label_of(doc, id); - if text.trim().is_empty() { - if let Some(l) = existing { - doc.remove(l)?; - } - return Ok(None); - } - if let Some(l) = existing { - if let Some(t) = text_of(doc, l) - && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) - { - let len = td.char_count(); - td.replace_range(0, len, text); - } - return Ok(Some(l)); - } - let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; - let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { - n.fills = vec![Paint::solid(Color::WHITE)]; - n.corners.radii = [4.0; 4]; - n.clip = false; - n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; - n.board = Some(BoardItem::Label { connector: id }); - })?; - let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; - if let Some(n) = doc.node_mut(t) - && let NodeKind::Text(td) = &mut n.kind - { - // The label hugs its text: one line per paragraph. - td.auto_resize = TextAutoResize::WidthAndHeight; - n.child_layout.sizing_h = Sizing::Hug; - } - Ok(Some(l)) -} - -fn label_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.label"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, _) = connector_of(cmd, s.doc()?, id)?; - let text = req_string(cmd, p, "text")?; - let label = set_label(s.doc_mut()?, id, &text)?; - Ok(json!({"id": id, "label": label})) -} - -/// The board objects among `ids`, each with its item (a label counts as its connector's text). -fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { - ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() -} - -fn set_color(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setColor"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - match item { - BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], - BoardItem::Shape { .. } => { - let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; - let n = doc.get_mut(outline)?; - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - } - BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], - BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, - } - changed.push(id); - } - if changed.is_empty() { - return Err(bad(cmd, "select stickies, shapes, connectors or ink")); - } - Ok(json!({"ids": changed})) -} - -/// What `board.setStyle` changes (each `None` leaves it). -struct Restyle { - shape: Option, - routing: Option, - size: Option, - start: Option, - end: Option, -} - -impl Restyle { - fn of(cmd: &str, p: &Value) -> Result { - let size = num(p, "fontSize") - .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) - .transpose()?; - Ok(Restyle { - shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, - routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, - size, - start: cap_of(cmd, p, "startCap")?, - end: cap_of(cmd, p, "endCap")?, - }) - } - - /// Restyle board object `id` (`item`); whether anything about it changed. - fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { - let changed = match item { - BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { - Some(new) => { - reshape(doc, id, new)?; - true - } - None => false, - }, - BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, - _ => false, - }; - // Text sizes: shapes' and labels' text (stickies size theirs to fit). - let sized = match (self.size, &item) { - (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), - _ => false, - }; - Ok(changed || sized) - } - - fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { - let n = doc.get_mut(id)?; - if let Some(r) = self.routing { - c.routing = r; - n.board = Some(BoardItem::Connector(c)); - } - if let Some(cap) = self.start { - n.stroke.cap_start = cap; - } - if let Some(cap) = self.end { - n.stroke.cap_end = cap; - } - Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) - } -} - -/// Shape with text `id` takes outline `new` (its text room too). -fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { - let n = doc.get_mut(id)?; - n.board = Some(BoardItem::Shape { shape: new }); - n.name = new.label().into(); - let (w, h) = (n.width, n.height); - if let Some(l) = n.layout.as_mut() { - l.padding = shape_padding(new, w, h); - } - if let Some(o) = doc.get(id)?.children.first().copied() - && let Some(on) = doc.node_mut(o) - { - on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; - (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); - } - Ok(()) -} - -/// Board object `id`'s text at `size`; whether it has text. -fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { - let Some(t) = text_of(doc, id) else { return false }; - if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { - set_font_size(td, size); - return true; - } - false -} - -fn set_style(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setStyle"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let restyle = Restyle::of(cmd, p)?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - if restyle.apply(doc, id, item)? { - changed.push(id); - } - } - if changed.is_empty() { - return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); - } - Ok(json!({"ids": changed})) -} - -fn set_font_size(td: &mut TextData, size: f64) { - td.style.font_size = size; - for r in &mut td.runs { - r.style.font_size = size; - } -} - -/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole -/// subtrees pair up when they have the same shape (an instance made of a main component may not; -/// then only the roots do). -pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { - let mut map = BTreeMap::new(); - for (s, c) in sources.iter().zip(copies) { - let (a, b) = (doc.subtree(*s), doc.subtree(*c)); - if a.len() == b.len() { - map.extend(a.into_iter().zip(b)); - } else { - map.insert(*s, *c); - } - } - map -} - -/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the -/// originals in `doc` (`map`: original → copy). -pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { - map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() -} - -/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the -/// originals had them), copied connectors join the copies of what they joined and labels follow -/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a -/// connector's end there stays where it is drawn, as a point, and a label without its connector -/// goes. -pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { - for (copy, item) in originals { - let mut item = item.clone(); - let copied = |n: NodeId| map.contains_key(&n); - match &mut item { - BoardItem::Connector(c) if foreign => { - let ends = ends_now(doc, *copy); - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && !copied(node) - { - *end = End::Point { x: p.x, y: p.y }; - } - } - } - BoardItem::Label { connector } if foreign && !copied(*connector) => { - let _ = doc.remove(*copy); - continue; - } - BoardItem::Connector(_) | BoardItem::Label { .. } => {} - _ => continue, - } - item.remap(map); - if let Some(n) = doc.node_mut(*copy) { - n.board = Some(item); - } - } -} - -// --------------------------------------------------------------------------------------------- -// Derived data (the normalize pass). - -/// Before text is measured: a sticky's note takes the size its length calls for, and a resized -/// shape keeps its text inside its outline. -pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) { - return; - } - for &id in touched { - let Some(n) = doc.node(id) else { continue }; - if let Some(owner) = - owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) - && let Some(t) = text_of(doc, owner) - && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) - { - let size = sticky_font_size(&td.characters); - let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); - if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { - set_font_size(td, size); - } - continue; - } - if let Some(BoardItem::Shape { shape }) = n.board - && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) - { - let pad = shape_padding(shape, n.width, n.height); - if n.layout.as_ref().is_some_and(|l| l.padding != pad) - && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) - { - l.padding = pad; - } - } - } -} - -/// After layout: connectors run between their ends again (an end whose layer is gone stays where -/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only -/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a -/// touched (or gone) end, connector or layer around them. -pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) || touched.is_empty() { - return; - } - let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); - let (mut connectors, mut labels) = (Vec::new(), Vec::new()); - for n in doc.nodes() { - match &n.board { - Some(BoardItem::Connector(c)) => { - let ends = [c.from.node(), c.to.node()].into_iter().flatten(); - if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { - connectors.push((n.id, *c)); - } - } - Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), - _ => {} - } - } - let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); - for (id, mut c) in connectors { - let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); - if changed && let Some(n) = doc.node_mut(id) { - n.board = Some(BoardItem::Connector(c)); - } - if let Some(pts) = route(doc, &c) { - place_route(doc, id, &pts, c.routing); - } - } - for (label, connector) in labels { - if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { - place_label(doc, label, connector); - } - } -} - -/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are -/// drawn, as points. Whether any did. -fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { - let (page, ends) = (doc.page_of(id), ends_now(doc, id)); - let mut detached = false; - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && (!doc.contains(node) || doc.page_of(node) != page) - { - *end = End::Point { x: p.x, y: p.y }; - detached = true; - } - } - detached -} - -/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it -/// joins): its free ends move with it. Whether they did. -fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { - let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; - if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { - return false; - } - let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; - let d = (doc.world_transform(id) * Point::ZERO) - expected; - if d.hypot() <= 1e-9 { - return false; - } - let mut moved = false; - for end in [&mut c.from, &mut c.to] { - if let End::Point { x, y } = end { - (*x, *y) = (*x + d.x, *y + d.y); - moved = true; - } - } - moved -} - -/// Label `label` at the middle of connector `connector`; gone with it when it is gone. -fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { - let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { - let _ = doc.remove(label); - return; - }; - let Some(n) = doc.node(label) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * mid; - let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); - if n.transform != t - && let Some(n) = doc.node_mut(label) - { - n.transform = t; - } -} - -/// Connector `id`'s first and last points on the page as it is drawn now. -fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) -} - -/// The middle of connector `id`'s route by length, on the page. -fn route_mid(doc: &Document, id: NodeId) -> Option { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - let mut pts: Vec = Vec::new(); - figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { - if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { - pts.push(w * p); - } - }); - let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); - let mut left = total / 2.0; - for s in pts.windows(2) { - let (a, b) = (s[0], s[1]); - let l = (b - a).hypot(); - if l >= left && l > 0.0 { - return Some(a.lerp(b, left / l)); - } - left -= l; - } - pts.first().copied() -} - -/// The path through a route's page points `pts` (see [`route`]): one curve for a curved -/// connector, straight runs otherwise. -fn route_path(pts: &[Point], routing: Routing) -> BezPath { - let mut path = BezPath::new(); - match (routing, pts) { - (Routing::Curved, [p0, c0, c1, p1]) => { - path.move_to(*p0); - path.curve_to(*c0, *c1, *p1); - } - _ => { - for (i, p) in pts.iter().enumerate() { - if i == 0 { path.move_to(*p) } else { path.line_to(*p) } - } - } - } - path -} - -/// The box a path is drawn in, when it is finite. -fn finite_box(path: &BezPath) -> Option { - let b = path.bounding_box(); - (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) -} - -/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and -/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. -fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { - let Some(n) = doc.node(id) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * route_path(pts, routing); - let Some(b) = finite_box(&local) else { return }; - let rel = Affine::translate((-b.x0, -b.y0)) * local; - let network = match routing { - Routing::Curved => VectorNetwork::from_path(&rel), - _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), - }; - let t = Affine::translate((b.x0, b.y0)); - let same = - n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); - if same { - return; - } - if let Some(n) = doc.node_mut(id) { - n.kind = NodeKind::Vector { network }; - n.transform = t; - (n.width, n.height) = (b.width(), b.height()); - } -} - -/// How far an elbow connector going around the objects it joins keeps from them. -const ROOM: f64 = 24.0; - -/// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. -pub fn route(doc: &Document, c: &Connector) -> Option> { - let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); - let centre = |e: &End, b: Option| match e { - End::Point { x, y } => Some(Point::new(*x, *y)), - End::Node { .. } => b.map(|b| b.center()), - }; - let (fb, tb) = (bounds(&c.from), bounds(&c.to)); - let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, s0) = attach(&c.from, fb, tc)?; - let (p1, s1) = attach(&c.to, tb, fc)?; - let pts = match c.routing { - Routing::Straight => tidy(vec![p0, p1]), - Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), - Routing::Curved => curve(p0, s0, p1, s1), - }; - pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) -} - -/// `pts` without repeats or turns that don't turn (three in a line across or down). -fn tidy(pts: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(pts.len()); - for p in pts { - if out.last() == Some(&p) { - continue; - } - if let [.., a, b] = out.as_slice() - && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) - { - out.pop(); - } - out.push(p); - } - out -} - -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that -/// side (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { - match e { - End::Point { x, y } => Some((Point::new(*x, *y), None)), - End::Node { side, .. } => { - let b = b?; - let side = match side { - Side::Auto => { - let d = other - b.center(); - // Compare against the box's proportions: a wide box takes the sides more. - if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { - if d.x >= 0.0 { Side::Right } else { Side::Left } - } else if d.y >= 0.0 { - Side::Bottom - } else { - Side::Top - } - } - s => *s, - }; - Some((side.point(b), Some(side))) - } - } -} - -/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no -/// way): the ends and the two control points between them. -fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { - let reach = (p1 - p0).hypot() / 2.0; - let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); - vec![p0, pull(p0, s0), pull(p1, s1), p1] -} - -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a -/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, -/// else the cheapest of a few ways around them (by length, and turns). -fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { - let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); - let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; - if !crosses(&direct, boxes) { - return direct; - } - // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. - let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); - let (a, b) = (out(p0, s0), out(p1, s1)); - let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; - for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { - ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); - } - for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { - ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); - } - let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; - ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) -} - -/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, -/// or out of one, is outside). -fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { - const EDGE: f64 = 0.5; - pts.windows(2).any(|s| { - let (a, b) = (s[0], s[1]); - boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) - }) -} - -/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end -/// follows the other): halfway across between two sides facing the same way, one turn otherwise. -fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { - let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); - let h1 = h1.unwrap_or(h0); - match (h0, h1) { - (true, true) => { - let mx = (p0.x + p1.x) / 2.0; - vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] - } - (false, false) => { - let my = (p0.y + p1.y) / 2.0; - vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] - } - (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], - (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], - } -} - -// --------------------------------------------------------------------------------------------- -// The board tools. - -impl Session { - /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, - /// or start erasing. - pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { - // A board tool kept while a design file came to the front: back to Move for this press. - if !is_board(self.doc()?) { - self.tool.tool = ToolId::Move; - return self.pointer(ev); - } - // A sticky still being typed in ends first, so the next object is an undo step of its own. - if self.ds()?.text_edit.is_some() { - crate::textedit::end(self)?; - } - match self.tool.tool { - ToolId::Sticky => { - let half = STICKY / 2.0; - let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - if let Some(id) = r.get("id").and_then(id_of) { - edit_text(self, id)?; - } - Ok(()) - } - ToolId::BoardShape => { - let half = SHAPE / 2.0; - self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - Ok(()) - } - ToolId::Stamp => { - // Stamps go on one after another: the tool stays. - self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; - Ok(()) - } - ToolId::Marker | ToolId::Highlighter => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; - Ok(()) - } - ToolId::Connector => { - let from = self.end_at(ev); - self.tool.gesture = Gesture::Connect { from, start: ev.p() }; - Ok(()) - } - ToolId::Eraser => { - self.begin_interaction("Erase")?; - self.tool.gesture = Gesture::Erase { gone: Vec::new() }; - self.erase_at(ev) - } - _ => Ok(()), - } - } - - /// After placing an object: back to the Move tool unless tools stay on. - fn board_placed(&mut self) { - if !self.prefs.keep_tool { - self.set_tool(ToolId::Move); - } - } - - /// What a connector end at the pointer attaches to: the object there, or the point. - fn end_at(&self, ev: &PointerEvent) -> End { - let hit = (|| { - let doc = self.doc().ok()?; - let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; - let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); - let n = doc.node(t)?; - (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) - })(); - match hit { - Some(node) => End::Node { node, side: Side::Auto }, - None => End::Point { x: ev.x, y: ev.y }, - } - } - - pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { - let a = match from { - End::Point { x, y } => Point::new(x, y), - End::Node { .. } => start, - }; - self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); - self.tool.overlay.drop_target = from.node(); - self.tool.gesture = Gesture::Connect { from, start }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - self.tool.overlay.drop_target = None; - // A click, not a drag: nothing to join. - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let to = self.end_at(ev); - if to.node().is_some() && to.node() == from.node() { - return Ok(()); - } - let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; - self.board_placed(); - Ok(()) - } - - /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end - /// to the pointer, and the object it would attach to outlined. - pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { - self.tool.gesture = Gesture::Reattach { id, from, start }; - let Ok(doc) = self.doc() else { return }; - let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); - let other = other_end(doc, id, from).and_then(|e| e.node()); - self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); - self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). - /// A click, or a drop on the object the other end is on, changes nothing. - pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let target = self.end_at(ev); - let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); - if target.node().is_some() && target.node() == other { - return Ok(()); - } - self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; - Ok(()) - } - - /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the - /// gesture ends, which is one undo step). - pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { - let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; - let tol = ERASER_PX / ev.zoom.max(1e-6); - self.tool.overlay.brush = Some([ev.x, ev.y, tol]); - let (doc, page) = (self.doc()?.clone(), self.page()?); - if let Some(hit) = - doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) - { - gone.push(hit); - let ids = gone.clone(); - self.preview(|d, sel| { - for i in &ids { - let _ = d.remove(*i); - } - sel.retain(|s| !ids.contains(s)); - Ok(()) - })?; - } - self.tool.gesture = Gesture::Erase { gone }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - Ok(()) - } - - pub(crate) fn erase_up(&mut self) -> Result<()> { - self.tool.overlay.brush = None; - self.commit_interaction() - } - - /// A finished marker or highlighter stroke (page points). - pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { - let high = self.tool.tool == ToolId::Highlighter; - self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; - // Strokes come one after another: the selection stays as it was. - Ok(()) - } -} - -impl Session { - /// A double-click with the Move tool on a board object: type in its text (a connector gets a - /// label to type in). True when it was one. - pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { - let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); - let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - match item { - BoardItem::Connector(_) => { - let label = match label_of(doc, whole) { - Some(l) => l, - None => { - let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; - r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? - } - }; - edit_text(self, label) - } - BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), - b if b.has_text() => edit_text(self, whole), - _ => Ok(false), - } - } -} diff --git a/crates/engine/src/boards.rs b/crates/engine/src/boards.rs new file mode 100644 index 000000000..685c1d70b --- /dev/null +++ b/crates/engine/src/boards.rs @@ -0,0 +1,3 @@ +pub mod board; +pub mod board_table; +pub mod board_templates; diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs new file mode 100644 index 000000000..b40bcc7d6 --- /dev/null +++ b/crates/engine/src/boards/board.rs @@ -0,0 +1,277 @@ +//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and +//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and +//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, +//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and +//! live-session edits from others all end in the same geometry. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::{BlendMode, Color, Paint}; +use figcraft_doc::{ + AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, + HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, + TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, +}; +use figcraft_geom::kurbo::PathEl; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; +use crate::tools::{Gesture, PointerEvent, ToolId}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +mod connectors; +mod derived; +mod edit; +mod items; +mod tools; + +pub(crate) use connectors::end_handle_at; +pub use connectors::{END_HANDLE_PX, selected_connector_ends}; +use connectors::{connector_cmd, reattach}; +pub use derived::route; +pub(crate) use derived::{settle_routes, settle_text}; +pub use edit::label_of; +pub(crate) use edit::{copied_items, copy_map, rejoin}; +use edit::{label_cmd, set_color, set_style}; +pub(crate) use items::add_text; +pub use items::{edit_text, owner_of_text, sticky_font_size, text_of}; +use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; + +/// Why a board command refuses on a design file. +pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; +/// Why prototyping and Dev Mode refuse on a board. +pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; + +/// A sticky note's side (it grows taller for long notes). +pub const STICKY: f64 = 200.0; +const STICKY_PAD: f64 = 16.0; +/// The room between a sticky and the next one made beside it. +const STICKY_GAP: f64 = 24.0; +/// A new shape's size. +const SHAPE: f64 = 160.0; +/// Text on board objects, and the author line under a sticky's note. +pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); +/// Connectors' default colour and weight. +const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); +const LINE_WEIGHT: f64 = 2.0; +/// Marker and highlighter strokes: (weight, opacity). +const MARKER: (f64, f32) = (4.0, 1.0); +const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); +/// How far the eraser reaches around the pointer (screen pixels). +const ERASER_PX: f64 = 8.0; +/// The text sizes the contextual bar offers. +pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; + +/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, +/// how new connectors run and the marker's and highlighter's colours. +#[derive(Clone, Debug)] +pub struct BoardTool { + pub sticky: Color, + pub shape: BoardShape, + pub stamp: Stamp, + pub routing: Routing, + pub marker: Color, + pub highlighter: Color, +} + +impl Default for BoardTool { + fn default() -> Self { + let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); + Self { + sticky: first, + shape: BoardShape::Rectangle, + stamp: Stamp::ThumbsUp, + routing: Routing::Elbow, + marker: INK, + highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), + } + } +} + +pub fn specs() -> Vec { + vec![ + spec!( + "board.sticky", + "Sticky note", + "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", + true, + sticky_cmd + ), + spec!( + "board.nextSticky", + "Next sticky note", + "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + true, + next_sticky + ), + spec!( + "board.shape", + "Shape with text", + "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", + true, + shape_cmd + ), + spec!( + "board.connector", + "Connector", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + true, + connector_cmd + ), + spec!( + "board.reattach", + "Reattach connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + true, + reattach + ), + spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), + spec!( + "board.stamp", + "Stamp", + "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", + true, + stamp_cmd + ), + spec!( + "board.ink", + "Marker stroke", + "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", + true, + ink_cmd + ), + spec!( + "board.setColor", + "Board color", + "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", + true, + set_color + ), + spec!( + "board.setStyle", + "Board style", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", + true, + set_style + ), + spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { + if let Some(c) = color_param("tool.sticky", p)? { + s.tool.board.sticky = c; + } + board_tool(s, ToolId::Sticky) + }), + spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { + if let Some(v) = string(p, "shape") { + s.tool.board.shape = shape_of("tool.shape", &v)?; + } + board_tool(s, ToolId::BoardShape) + }), + spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { + if let Some(v) = string(p, "style") { + s.tool.board.routing = routing_of("tool.connector", &v)?; + } + board_tool(s, ToolId::Connector) + }), + spec!("tool.marker", "Marker", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.marker", p)? { + s.tool.board.marker = c; + } + board_tool(s, ToolId::Marker) + }), + spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.highlighter", p)? { + s.tool.board.highlighter = c; + } + board_tool(s, ToolId::Highlighter) + }), + spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), + spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { + if let Some(v) = string(p, "stamp") { + s.tool.board.stamp = stamp_of("tool.stamp", &v)?; + } + board_tool(s, ToolId::Stamp) + }), + ] +} + +pub fn is_board(doc: &Document) -> bool { + doc.kind == FileKind::Board +} + +/// Refuse `cmd` unless the active file is a board. +pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { + if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } +} + +/// Prototyping and Dev Mode: not on boards. +fn design_only(cmd: &str) -> bool { + cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") +} + +/// Refuse `cmd` on a board when it is prototyping or Dev Mode. +pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { + if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } +} + +fn board_tool(s: &mut Session, t: ToolId) -> Result { + require(s, &format!("tool.{}", t.id()))?; + crate::cmd::view::tool(s, t) +} + +fn shape_of(cmd: &str, v: &str) -> Result { + BoardShape::from_id(v) + .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) +} + +fn stamp_of(cmd: &str, v: &str) -> Result { + Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) +} + +fn routing_of(cmd: &str, v: &str) -> Result { + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) +} + +fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), + } +} + +/// `color`: a board colour's name, or any colour. +fn color_param(cmd: &str, p: &Value) -> Result> { + match get(p, "color") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), + Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), + } +} + +/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). +fn line_shade(c: Color) -> Color { + let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); + if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } +} + +/// Your name on what you make: the name from Preferences, else the one a live session gave you +/// (none for the default name outside a session). +pub(crate) fn author(s: &Session) -> String { + let name = crate::live::display_name(&s.prefs); + if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } +} + +/// The parent a command places into, and that parent's page-to-local transform. +fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { + let page = s.page()?; + let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); + let doc = s.doc()?; + let n = doc.get(parent)?; + let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); + if !takes { + return Err(bad(cmd, "`parent` is the page, a section or a frame")); + } + Ok(parent) +} diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs new file mode 100644 index 000000000..753550e5b --- /dev/null +++ b/crates/engine/src/boards/board/connectors.rs @@ -0,0 +1,147 @@ +use super::derived::{ends_now, place_route, route}; +use super::edit::set_label; +use super::items::add; +use super::*; + +/// One connector end from the params: a layer on this page, or a point. +fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { + let side = match string(p, side_key) { + Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, + None => Side::Auto, + }; + match get(p, key) { + Some(Value::Object(o)) if o.contains_key("x") => { + let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), + _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), + } + } + Some(v) => { + let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; + let doc = s.doc()?; + let n = doc.get(node)?; + if n.is_page() || doc.page_of(node) != Some(s.page()?) { + return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); + } + if matches!(n.board, Some(BoardItem::Connector(_))) { + return Err(bad(cmd, format!("`{key}` can't be another connector"))); + } + Ok(End::Node { node, side }) + } + None => Err(bad(cmd, format!("`{key}` is required"))), + } +} + +pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.connector"; + require(s, cmd)?; + let from = end_param(cmd, s, p, "from", "fromSide")?; + let to = end_param(cmd, s, p, "to", "toSide")?; + if from.node().is_some() && from.node() == to.node() { + return Err(bad(cmd, "connect two different layers")); + } + let routing = match string(p, "style") { + Some(v) => routing_of(cmd, &v)?, + None => s.tool.board.routing, + }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); + let c = Connector { from, to, routing }; + let page = s.page()?; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(LINE)]; + n.stroke.weight = LINE_WEIGHT; + n.stroke.join = StrokeJoin::Round; + n.stroke.cap_start = start; + n.stroke.cap_end = end; + n.board = Some(BoardItem::Connector(c)); + })?; + place_route(doc, id, &pts, routing); + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// The connector `id` names: a connector, or the label on one. +pub(super) fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { + match doc.get(id)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((id, c)), + Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((connector, c)), + _ => Err(bad(cmd, "that isn't a connector")), + }, + _ => Err(bad(cmd, "that isn't a connector")), + } +} + +pub(super) fn reattach(s: &mut Session, p: &Value) -> Result { + let cmd = "board.reattach"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, mut c) = connector_of(cmd, s.doc()?, id)?; + let from = match string(p, "end").as_deref() { + Some("from") => true, + Some("to") => false, + _ => return Err(bad(cmd, "`end` is from or to")), + }; + let end = end_param(cmd, s, p, "target", "side")?; + let other = if from { c.to } else { c.from }; + if end.node().is_some() && end.node() == other.node() { + return Err(bad(cmd, "connect two different layers")); + } + *(if from { &mut c.from } else { &mut c.to }) = end; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); + place_route(doc, id, &pts, c.routing); + Ok(json!({"id": id})) +} + +/// A connector end as `board.connector` and `board.reattach` take it. +pub(super) fn end_json(e: End) -> Value { + match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + } +} + +/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. +pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { + match &doc.node(id)?.board { + Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), + _ => None, + } +} + +/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. +pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { + let [id] = sel else { return None }; + if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { + return None; + } + let (a, b) = ends_now(doc, *id)?; + Some((*id, [a, b])) +} + +/// How near (screen pixels) a press must be to a selected connector's end to drag it. +pub const END_HANDLE_PX: f64 = 8.0; + +/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). +pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { + let (id, [a, b]) = selected_connector_ends(doc, sel)?; + let (da, db) = ((a - p).hypot(), (b - p).hypot()); + (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) +} diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs new file mode 100644 index 000000000..ac73df1eb --- /dev/null +++ b/crates/engine/src/boards/board/derived.rs @@ -0,0 +1,339 @@ +use super::edit::set_font_size; +use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Derived data (the normalize pass). + +/// Before text is measured: a sticky's note takes the size its length calls for, and a resized +/// shape keeps its text inside its outline. +pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) { + return; + } + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + if let Some(owner) = + owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) + && let Some(t) = text_of(doc, owner) + && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) + { + let size = sticky_font_size(&td.characters); + let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); + if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { + set_font_size(td, size); + } + continue; + } + if let Some(BoardItem::Shape { shape }) = n.board + && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) + { + let pad = shape_padding(shape, n.width, n.height); + if n.layout.as_ref().is_some_and(|l| l.padding != pad) + && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) + { + l.padding = pad; + } + } + } +} + +/// After layout: connectors run between their ends again (an end whose layer is gone stays where +/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only +/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a +/// touched (or gone) end, connector or layer around them. +pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) || touched.is_empty() { + return; + } + let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); + let (mut connectors, mut labels) = (Vec::new(), Vec::new()); + for n in doc.nodes() { + match &n.board { + Some(BoardItem::Connector(c)) => { + let ends = [c.from.node(), c.to.node()].into_iter().flatten(); + if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { + connectors.push((n.id, *c)); + } + } + Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), + _ => {} + } + } + let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); + for (id, mut c) in connectors { + let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); + if changed && let Some(n) = doc.node_mut(id) { + n.board = Some(BoardItem::Connector(c)); + } + if let Some(pts) = route(doc, &c) { + place_route(doc, id, &pts, c.routing); + } + } + for (label, connector) in labels { + if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { + place_label(doc, label, connector); + } + } +} + +/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are +/// drawn, as points. Whether any did. +fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { + let (page, ends) = (doc.page_of(id), ends_now(doc, id)); + let mut detached = false; + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && (!doc.contains(node) || doc.page_of(node) != page) + { + *end = End::Point { x: p.x, y: p.y }; + detached = true; + } + } + detached +} + +/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it +/// joins): its free ends move with it. Whether they did. +fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; + if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { + return false; + } + let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; + let d = (doc.world_transform(id) * Point::ZERO) - expected; + if d.hypot() <= 1e-9 { + return false; + } + let mut moved = false; + for end in [&mut c.from, &mut c.to] { + if let End::Point { x, y } = end { + (*x, *y) = (*x + d.x, *y + d.y); + moved = true; + } + } + moved +} + +/// Label `label` at the middle of connector `connector`; gone with it when it is gone. +fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { + let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { + let _ = doc.remove(label); + return; + }; + let Some(n) = doc.node(label) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * mid; + let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); + if n.transform != t + && let Some(n) = doc.node_mut(label) + { + n.transform = t; + } +} + +/// Connector `id`'s first and last points on the page as it is drawn now. +pub(super) fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) +} + +/// The middle of connector `id`'s route by length, on the page. +fn route_mid(doc: &Document, id: NodeId) -> Option { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + let mut pts: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(w * p); + } + }); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let (a, b) = (s[0], s[1]); + let l = (b - a).hypot(); + if l >= left && l > 0.0 { + return Some(a.lerp(b, left / l)); + } + left -= l; + } + pts.first().copied() +} + +/// The path through a route's page points `pts` (see [`route`]): one curve for a curved +/// connector, straight runs otherwise. +fn route_path(pts: &[Point], routing: Routing) -> BezPath { + let mut path = BezPath::new(); + match (routing, pts) { + (Routing::Curved, [p0, c0, c1, p1]) => { + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + } + _ => { + for (i, p) in pts.iter().enumerate() { + if i == 0 { path.move_to(*p) } else { path.line_to(*p) } + } + } + } + path +} + +/// The box a path is drawn in, when it is finite. +fn finite_box(path: &BezPath) -> Option { + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and +/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. +pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { + let Some(n) = doc.node(id) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * route_path(pts, routing); + let Some(b) = finite_box(&local) else { return }; + let rel = Affine::translate((-b.x0, -b.y0)) * local; + let network = match routing { + Routing::Curved => VectorNetwork::from_path(&rel), + _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), + }; + let t = Affine::translate((b.x0, b.y0)); + let same = + n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); + if same { + return; + } + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Vector { network }; + n.transform = t; + (n.width, n.height) = (b.width(), b.height()); + } +} + +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + +/// The page points connector `c` runs through: from the middle of the side of each attached layer +/// facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. +pub fn route(doc: &Document, c: &Connector) -> Option> { + let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); + let centre = |e: &End, b: Option| match e { + End::Point { x, y } => Some(Point::new(*x, *y)), + End::Node { .. } => b.map(|b| b.center()), + }; + let (fb, tb) = (bounds(&c.from), bounds(&c.to)); + let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); + let (p0, s0) = attach(&c.from, fb, tc)?; + let (p1, s1) = attach(&c.to, tb, fc)?; + let pts = match c.routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), + Routing::Curved => curve(p0, s0, p1, s1), + }; + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { + match e { + End::Point { x, y } => Some((Point::new(*x, *y), None)), + End::Node { side, .. } => { + let b = b?; + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => *s, + }; + Some((side.point(b), Some(side))) + } + } +} + +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs new file mode 100644 index 000000000..1770852c6 --- /dev/null +++ b/crates/engine/src/boards/board/edit.rs @@ -0,0 +1,260 @@ +use super::connectors::connector_of; +use super::derived::ends_now; +use super::items::{add, add_text, shape_padding, text_of}; +use super::*; + +/// The label of connector `id`, if it has one. +pub fn label_of(doc: &Document, id: NodeId) -> Option { + let parent = doc.node(id)?.parent?; + doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) +} + +/// Give connector `id` the label `text` (empty removes it); the label's id. +pub(super) fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { + let existing = label_of(doc, id); + if text.trim().is_empty() { + if let Some(l) = existing { + doc.remove(l)?; + } + return Ok(None); + } + if let Some(l) = existing { + if let Some(t) = text_of(doc, l) + && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) + { + let len = td.char_count(); + td.replace_range(0, len, text); + } + return Ok(Some(l)); + } + let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; + let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [4.0; 4]; + n.clip = false; + n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Label { connector: id }); + })?; + let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; + if let Some(n) = doc.node_mut(t) + && let NodeKind::Text(td) = &mut n.kind + { + // The label hugs its text: one line per paragraph. + td.auto_resize = TextAutoResize::WidthAndHeight; + n.child_layout.sizing_h = Sizing::Hug; + } + Ok(Some(l)) +} + +pub(super) fn label_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.label"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, _) = connector_of(cmd, s.doc()?, id)?; + let text = req_string(cmd, p, "text")?; + let label = set_label(s.doc_mut()?, id, &text)?; + Ok(json!({"id": id, "label": label})) +} + +/// The board objects among `ids`, each with its item (a label counts as its connector's text). +fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { + ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() +} + +pub(super) fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setColor"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + match item { + BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], + BoardItem::Shape { .. } => { + let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; + let n = doc.get_mut(outline)?; + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + } + BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], + BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, + } + changed.push(id); + } + if changed.is_empty() { + return Err(bad(cmd, "select stickies, shapes, connectors or ink")); + } + Ok(json!({"ids": changed})) +} + +/// What `board.setStyle` changes (each `None` leaves it). +struct Restyle { + shape: Option, + routing: Option, + size: Option, + start: Option, + end: Option, +} + +impl Restyle { + fn of(cmd: &str, p: &Value) -> Result { + let size = num(p, "fontSize") + .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) + .transpose()?; + Ok(Restyle { + shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, + routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, + size, + start: cap_of(cmd, p, "startCap")?, + end: cap_of(cmd, p, "endCap")?, + }) + } + + /// Restyle board object `id` (`item`); whether anything about it changed. + fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { + let changed = match item { + BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { + Some(new) => { + reshape(doc, id, new)?; + true + } + None => false, + }, + BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, + _ => false, + }; + // Text sizes: shapes' and labels' text (stickies size theirs to fit). + let sized = match (self.size, &item) { + (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), + _ => false, + }; + Ok(changed || sized) + } + + fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { + let n = doc.get_mut(id)?; + if let Some(r) = self.routing { + c.routing = r; + n.board = Some(BoardItem::Connector(c)); + } + if let Some(cap) = self.start { + n.stroke.cap_start = cap; + } + if let Some(cap) = self.end { + n.stroke.cap_end = cap; + } + Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) + } +} + +/// Shape with text `id` takes outline `new` (its text room too). +fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { + let n = doc.get_mut(id)?; + n.board = Some(BoardItem::Shape { shape: new }); + n.name = new.label().into(); + let (w, h) = (n.width, n.height); + if let Some(l) = n.layout.as_mut() { + l.padding = shape_padding(new, w, h); + } + if let Some(o) = doc.get(id)?.children.first().copied() + && let Some(on) = doc.node_mut(o) + { + on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; + (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); + } + Ok(()) +} + +/// Board object `id`'s text at `size`; whether it has text. +fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { + let Some(t) = text_of(doc, id) else { return false }; + if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { + set_font_size(td, size); + return true; + } + false +} + +pub(super) fn set_style(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setStyle"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let restyle = Restyle::of(cmd, p)?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + if restyle.apply(doc, id, item)? { + changed.push(id); + } + } + if changed.is_empty() { + return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); + } + Ok(json!({"ids": changed})) +} + +pub(super) fn set_font_size(td: &mut TextData, size: f64) { + td.style.font_size = size; + for r in &mut td.runs { + r.style.font_size = size; + } +} + +/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole +/// subtrees pair up when they have the same shape (an instance made of a main component may not; +/// then only the roots do). +pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { + let mut map = BTreeMap::new(); + for (s, c) in sources.iter().zip(copies) { + let (a, b) = (doc.subtree(*s), doc.subtree(*c)); + if a.len() == b.len() { + map.extend(a.into_iter().zip(b)); + } else { + map.insert(*s, *c); + } + } + map +} + +/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the +/// originals in `doc` (`map`: original → copy). +pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { + map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() +} + +/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the +/// originals had them), copied connectors join the copies of what they joined and labels follow +/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a +/// connector's end there stays where it is drawn, as a point, and a label without its connector +/// goes. +pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { + for (copy, item) in originals { + let mut item = item.clone(); + let copied = |n: NodeId| map.contains_key(&n); + match &mut item { + BoardItem::Connector(c) if foreign => { + let ends = ends_now(doc, *copy); + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && !copied(node) + { + *end = End::Point { x: p.x, y: p.y }; + } + } + } + BoardItem::Label { connector } if foreign && !copied(*connector) => { + let _ = doc.remove(*copy); + continue; + } + BoardItem::Connector(_) | BoardItem::Label { .. } => {} + _ => continue, + } + item.remap(map); + if let Some(n) = doc.node_mut(*copy) { + n.board = Some(item); + } + } +} diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs new file mode 100644 index 000000000..6d96d34b4 --- /dev/null +++ b/crates/engine/src/boards/board/items.rs @@ -0,0 +1,297 @@ +use super::*; + +/// A new layer at the top of `parent`, at `at` in it, made by `f`. +pub(super) fn add( + doc: &mut Document, + parent: NodeId, + kind: NodeKind, + name: &str, + at: (f64, f64), + size: (f64, f64), + f: impl FnOnce(&mut Node), +) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, size.0, size.1); + n.transform = Affine::translate(at); + f(&mut n); + doc.insert(n, parent, None)?; + Ok(id) +} + +/// A soft shadow under stickies and stamps. +fn soft_shadow() -> Effect { + Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), + visible: true, + } +} + +/// A text layer filling its frame's width (the frame's auto layout places it). +pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { + let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); + let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; + let mut td = TextData { + characters: crate::cmd::props::normalize_line_endings(text), + auto_resize: TextAutoResize::Height, + align_h: align, + ..TextData::default() + }; + td.style.font_size = size; + let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { + n.fills = vec![Paint::solid(color)]; + n.child_layout.sizing_h = Sizing::Fill; + })?; + if let Some(n) = doc.node(id) + && let Some((w, h)) = figcraft_text::auto_size(n) + && let Some(n) = doc.node_mut(id) + { + (n.width, n.height) = (w, h); + } + Ok(id) +} + +/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so +/// every copy of the file picks the same size whatever fonts it has). +pub fn sticky_font_size(text: &str) -> f64 { + let weight = text.chars().count() + 24 * text.matches('\n').count(); + match weight { + 0..=24 => 24.0, + 25..=64 => 20.0, + 65..=140 => 16.0, + 141..=280 => 13.0, + _ => 11.0, + } +} + +/// A sticky note's layers: the frame, the note and (when known) its author. +fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { + let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { + n.fills = vec![Paint::solid(color)]; + n.corners.radii = [2.0; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + gap: 8.0, + padding: [STICKY_PAD; 4], + main_align: MainAlign::SpaceBetween, + ..AutoLayout::default() + }); + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(STICKY); + n.board = Some(BoardItem::Sticky { author: author.to_string() }); + })?; + let inner = STICKY - 2.0 * STICKY_PAD; + let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; + if !author.is_empty() { + let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; + if let Some(n) = doc.node_mut(a) { + n.name = "Author".into(); + } + } + Ok((id, note)) +} + +pub(super) fn sticky_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.sticky"; + require(s, cmd)?; + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let author = match get(p, "author") { + Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), + Some(Value::Bool(false)) => String::new(), + _ => author(s), + }; + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": note})) +} + +/// The first text layer inside board object `id` (its note or label). +pub fn text_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + n.board.as_ref().filter(|b| b.has_text())?; + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) +} + +/// The board object whose text `text` is, if any. +pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { + let p = doc.node(text)?.parent?; + (text_of(doc, p) == Some(text)).then_some(p) +} + +/// Start typing in board object `id`'s text (all of it selected). False when it has none. +pub fn edit_text(s: &mut Session, id: NodeId) -> Result { + let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; + textedit::begin(s, t, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} + +pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { + let cmd = "board.nextSticky"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + let n = doc.get(from)?; + if !matches!(n.board, Some(BoardItem::Sticky { .. })) { + return Err(bad(cmd, "that isn't a sticky")); + } + let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); + let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; + let at = n.transform.translation(); + let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { + "right" => (n.width + STICKY_GAP, 0.0), + "left" => (-(STICKY + STICKY_GAP), 0.0), + "below" => (0.0, n.height + STICKY_GAP), + "above" => (0.0, -(STICKY + STICKY_GAP)), + other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), + }; + let author = author(s); + if editing.is_some() { + textedit::end(s)?; + } + let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + // Typing goes on in the new sticky once this step is recorded. + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": note})) +} + +/// A shape with text's layers: the frame, its outline and its text. +fn make_shape( + doc: &mut Document, + parent: NodeId, + at: (f64, f64), + size: (f64, f64), + shape: BoardShape, + text: &str, + color: Color, +) -> Result<(NodeId, NodeId)> { + let (w, h) = size; + let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { + n.fills.clear(); + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + padding: shape_padding(shape, w, h), + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + ..AutoLayout::default() + }); + n.board = Some(BoardItem::Shape { shape }); + })?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + n.stroke.weight = 1.5; + n.stroke.align = StrokeAlign::Inside; + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + })?; + let [_, r, _, l] = shape_padding(shape, w, h); + let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; + Ok((id, label)) +} + +pub(super) fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { + let [t, r, b, l] = shape.text_insets(); + [t * h, r * w, b * h, l * w] +} + +pub(super) fn shape_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.shape"; + require(s, cmd)?; + let shape = match string(p, "shape") { + Some(v) => shape_of(cmd, &v)?, + None => s.tool.board.shape, + }; + let size = |k| { + num(p, k) + .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) + }; + let (w, h) = (size("width")?, size("height")?); + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": label})) +} + +pub(super) fn stamp_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.stamp"; + require(s, cmd)?; + let stamp = match string(p, "stamp") { + Some(v) => stamp_of(cmd, &v)?, + None => s.tool.board.stamp, + }; + let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let author = author(s); + let page = s.page()?; + let half = STAMP_SIZE / 2.0; + let doc = s.doc_mut()?; + let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { + n.fills = vec![Paint::solid(stamp.color())]; + n.corners.radii = [half; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.board = Some(BoardItem::Stamp { stamp, author }); + })?; + for part in stamp.mark() { + let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { + if part.filled { + n.fills = vec![Paint::solid(Color::WHITE)]; + } else { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::WHITE)]; + n.stroke.weight = 3.0; + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); + n.stroke.join = StrokeJoin::Round; + n.stroke.align = StrokeAlign::Center; + } + })?; + } + s.set_selection(vec![id]); + Ok(json!({"id": id})) +} + +pub(super) fn ink_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.ink"; + require(s, cmd)?; + let high = boolean(p, "highlighter").unwrap_or(false); + let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); + let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; + if get(p, "path").is_none() && get(p, "points").is_none() { + return Err(bad(cmd, "`path` or `points` is required")); + } + let page = s.page()?; + let select = boolean(p, "select").unwrap_or(true); + let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { + "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", + }}); + for k in ["path", "points"] { + if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { + o.insert(k.into(), v.clone()); + } + } + let made = crate::cmd::create::create(s, &q)?; + let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; + let n = s.doc_mut()?.get_mut(id)?; + n.opacity = opacity; + if high { + n.blend = BlendMode::Multiply; + } + n.board = Some(BoardItem::Ink); + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs new file mode 100644 index 000000000..2dc904c94 --- /dev/null +++ b/crates/engine/src/boards/board/tools.rs @@ -0,0 +1,210 @@ +use super::connectors::{end_json, other_end}; +use super::derived::ends_now; +use super::edit::label_of; +use super::items::edit_text; +use super::*; + +// --------------------------------------------------------------------------------------------- +// The board tools. + +impl Session { + /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, + /// or start erasing. + pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { + // A board tool kept while a design file came to the front: back to Move for this press. + if !is_board(self.doc()?) { + self.tool.tool = ToolId::Move; + return self.pointer(ev); + } + // A sticky still being typed in ends first, so the next object is an undo step of its own. + if self.ds()?.text_edit.is_some() { + crate::textedit::end(self)?; + } + match self.tool.tool { + ToolId::Sticky => { + let half = STICKY / 2.0; + let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + if let Some(id) = r.get("id").and_then(id_of) { + edit_text(self, id)?; + } + Ok(()) + } + ToolId::BoardShape => { + let half = SHAPE / 2.0; + self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + Ok(()) + } + ToolId::Stamp => { + // Stamps go on one after another: the tool stays. + self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; + Ok(()) + } + ToolId::Marker | ToolId::Highlighter => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Connector => { + let from = self.end_at(ev); + self.tool.gesture = Gesture::Connect { from, start: ev.p() }; + Ok(()) + } + ToolId::Eraser => { + self.begin_interaction("Erase")?; + self.tool.gesture = Gesture::Erase { gone: Vec::new() }; + self.erase_at(ev) + } + _ => Ok(()), + } + } + + /// After placing an object: back to the Move tool unless tools stay on. + fn board_placed(&mut self) { + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + + /// What a connector end at the pointer attaches to: the object there, or the point. + fn end_at(&self, ev: &PointerEvent) -> End { + let hit = (|| { + let doc = self.doc().ok()?; + let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); + let n = doc.node(t)?; + (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + })(); + match hit { + Some(node) => End::Node { node, side: Side::Auto }, + None => End::Point { x: ev.x, y: ev.y }, + } + } + + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { + let a = match from { + End::Point { x, y } => Point::new(x, y), + End::Node { .. } => start, + }; + self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); + self.tool.overlay.drop_target = from.node(); + self.tool.gesture = Gesture::Connect { from, start }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + self.tool.overlay.drop_target = None; + // A click, not a drag: nothing to join. + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let to = self.end_at(ev); + if to.node().is_some() && to.node() == from.node() { + return Ok(()); + } + let style = self.tool.board.routing.id(); + self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; + self.board_placed(); + Ok(()) + } + + /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end + /// to the pointer, and the object it would attach to outlined. + pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { + self.tool.gesture = Gesture::Reattach { id, from, start }; + let Ok(doc) = self.doc() else { return }; + let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); + let other = other_end(doc, id, from).and_then(|e| e.node()); + self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); + self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). + /// A click, or a drop on the object the other end is on, changes nothing. + pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let target = self.end_at(ev); + let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); + if target.node().is_some() && target.node() == other { + return Ok(()); + } + self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + Ok(()) + } + + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the + /// gesture ends, which is one undo step). + pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { + let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; + let tol = ERASER_PX / ev.zoom.max(1e-6); + self.tool.overlay.brush = Some([ev.x, ev.y, tol]); + let (doc, page) = (self.doc()?.clone(), self.page()?); + if let Some(hit) = + doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) + { + gone.push(hit); + let ids = gone.clone(); + self.preview(|d, sel| { + for i in &ids { + let _ = d.remove(*i); + } + sel.retain(|s| !ids.contains(s)); + Ok(()) + })?; + } + self.tool.gesture = Gesture::Erase { gone }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } + + pub(crate) fn erase_up(&mut self) -> Result<()> { + self.tool.overlay.brush = None; + self.commit_interaction() + } + + /// A finished marker or highlighter stroke (page points). + pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { + let high = self.tool.tool == ToolId::Highlighter; + self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; + // Strokes come one after another: the selection stays as it was. + Ok(()) + } +} + +impl Session { + /// A double-click with the Move tool on a board object: type in its text (a connector gets a + /// label to type in). True when it was one. + pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { + let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); + let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + match item { + BoardItem::Connector(_) => { + let label = match label_of(doc, whole) { + Some(l) => l, + None => { + let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; + r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? + } + }; + edit_text(self, label) + } + BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), + b if b.has_text() => edit_text(self, whole), + _ => Ok(false), + } + } +} diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/boards/board_table.rs similarity index 100% rename from crates/engine/src/board_table.rs rename to crates/engine/src/boards/board_table.rs diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/boards/board_templates.rs similarity index 100% rename from crates/engine/src/board_templates.rs rename to crates/engine/src/boards/board_templates.rs diff --git a/crates/engine/src/canvas.rs b/crates/engine/src/canvas.rs new file mode 100644 index 000000000..b513ffbde --- /dev/null +++ b/crates/engine/src/canvas.rs @@ -0,0 +1,14 @@ +pub mod al_keys; +pub mod arc_handles; +pub mod booleans; +pub mod distance; +pub mod drop; +pub mod guides; +pub mod rotation_origin; +pub mod shape_handles; +pub mod shapebuilder; +pub mod snap; +pub mod textedit; +pub mod textpreview; +pub mod tools; +pub mod vectoredit; diff --git a/crates/engine/src/al_keys.rs b/crates/engine/src/canvas/al_keys.rs similarity index 100% rename from crates/engine/src/al_keys.rs rename to crates/engine/src/canvas/al_keys.rs diff --git a/crates/engine/src/arc_handles.rs b/crates/engine/src/canvas/arc_handles.rs similarity index 100% rename from crates/engine/src/arc_handles.rs rename to crates/engine/src/canvas/arc_handles.rs diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/canvas/booleans.rs similarity index 100% rename from crates/engine/src/booleans.rs rename to crates/engine/src/canvas/booleans.rs diff --git a/crates/engine/src/distance.rs b/crates/engine/src/canvas/distance.rs similarity index 100% rename from crates/engine/src/distance.rs rename to crates/engine/src/canvas/distance.rs diff --git a/crates/engine/src/drop.rs b/crates/engine/src/canvas/drop.rs similarity index 100% rename from crates/engine/src/drop.rs rename to crates/engine/src/canvas/drop.rs diff --git a/crates/engine/src/guides.rs b/crates/engine/src/canvas/guides.rs similarity index 100% rename from crates/engine/src/guides.rs rename to crates/engine/src/canvas/guides.rs diff --git a/crates/engine/src/rotation_origin.rs b/crates/engine/src/canvas/rotation_origin.rs similarity index 100% rename from crates/engine/src/rotation_origin.rs rename to crates/engine/src/canvas/rotation_origin.rs diff --git a/crates/engine/src/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs similarity index 100% rename from crates/engine/src/shape_handles.rs rename to crates/engine/src/canvas/shape_handles.rs diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs similarity index 100% rename from crates/engine/src/shapebuilder.rs rename to crates/engine/src/canvas/shapebuilder.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/canvas/snap.rs similarity index 100% rename from crates/engine/src/snap.rs rename to crates/engine/src/canvas/snap.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/canvas/textedit.rs similarity index 53% rename from crates/engine/src/textedit.rs rename to crates/engine/src/canvas/textedit.rs index 2cd9bf972..91e411e54 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -796,576 +796,4 @@ pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> { } #[cfg(test)] -mod tests { - use super::*; - use crate::tools::{PointerEvent, PointerKind}; - use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; - use serde_json::json; - - fn setup(text: &str) -> (Session, NodeId) { - let mut s = Session::new(); - let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); - let id = crate::params::id_of(&r["id"]).unwrap(); - (s, id) - } - - fn data(s: &Session, id: NodeId) -> TextData { - s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() - } - - fn sel(s: &Session) -> (usize, usize) { - let te = s.ds().unwrap().text_edit.clone().unwrap(); - (te.anchor, te.caret) - } - - #[test] - fn whole_editing_session_is_one_undo_step() { - let (mut s, id) = setup("Hello"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - insert(&mut s, " big").unwrap(); - insert(&mut s, " world").unwrap(); - key(&mut s, "Backspace", false, false, false).unwrap(); - // Styling a range mid-session joins the same step. - set_caret(&mut s, 0, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.bold", &json!({})).unwrap(); - s.execute("text.alignCenter", &json!({})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello big worl"); - assert_eq!(t.style_at(0).font_style, "Bold"); - assert_eq!(t.style_at(6).font_style, "Regular"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - s.undo().unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello"); - assert!(t.runs.is_empty()); - assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); - } - - #[test] - fn typing_replaces_selection_and_paste_is_plain() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, None, false).unwrap(); - assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); - select_word(&mut s, 7).unwrap(); - assert_eq!(sel(&s), (6, 11)); - insert(&mut s, "there").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there"); - assert_eq!(sel(&s), (11, 11)); - // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. - insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - insert(&mut s, "\u{1}").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - // Select the line (paragraph) then type over it. - select_line(&mut s, 0).unwrap(); - assert_eq!(sel(&s), (0, 11)); - insert(&mut s, "Hi").unwrap(); - assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); - assert_eq!(selected_text(&s).as_deref(), Some("")); - end(&mut s).unwrap(); - } - - #[test] - fn word_selection_classes() { - let (mut s, id) = setup("foo, bar_baz!\nnext"); - begin(&mut s, id, Some(0), false).unwrap(); - select_word(&mut s, 1).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Right after a word's last char selects the word. - select_word(&mut s, 13).unwrap(); - assert_eq!(sel(&s), (6, 13)); - // A run of spaces. - select_word(&mut s, 4).unwrap(); - assert_eq!(sel(&s), (4, 6)); - select_line(&mut s, 16).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_line(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_word(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - set_caret(&mut s, 999, false).unwrap(); - assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); - } - - #[test] - fn keyboard_navigation_and_extension() { - let (mut s, id) = setup("one two\nthree"); - begin(&mut s, id, Some(0), false).unwrap(); - key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends - assert_eq!(sel(&s), (3, 7)); - key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end - assert_eq!(sel(&s), (7, 7)); - key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end - assert_eq!(sel(&s), (7, 13)); - key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start - assert_eq!(sel(&s), (0, 0)); - key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward - assert_eq!(data(&s, id).characters, " two\nthree"); - key(&mut s, "SelectAll", false, false, false).unwrap(); - assert_eq!(sel(&s), (0, 10)); - end(&mut s).unwrap(); - } - - #[test] - fn backspace_removes_only_the_final_thai_mark() { - let (mut s, id) = setup("น้ำ"); - begin(&mut s, id, Some(3), false).unwrap(); - - key(&mut s, "Backspace", false, false, false).unwrap(); - - assert_eq!(data(&s, id).characters, "น้"); - } - - fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { - let mut e = PointerEvent::at(kind, x, y, 1.0); - e.shift = shift; - e - } - - /// Asserts double-clicking a text layer selects all text, a second double-click selects the - /// word without changing the editing session, and drag and Shift+click extend the - /// selection. - #[test] - fn pointer_double_click_drag_and_shift_click() { - let (mut s, id) = setup("Hello world"); - let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; - if l.glyphs.is_empty() { - return; // no fonts - } - let y = l.lines[0].top + l.lines[0].height / 2.0; - let x_of = |i: usize| l.caret(i).0; - // Double-click inside "world" edits with all the text selected; another one there - // selects the word. - let mid = (x_of(7) + x_of(8)) / 2.0; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 11)); - let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (6, 11)); - assert_eq!( - s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, - epoch, - "a double-click inside the edit is an ordinary edit: same IME replacement epoch" - ); - // Drag-select from the start of "Hello" to the middle. - s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 0)); - s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Shift+click extends. - s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); - s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); - assert_eq!(sel(&s), (0, 9)); - insert(&mut s, "J").unwrap(); - assert_eq!(data(&s, id).characters, "Jld"); - } - - #[test] - fn text_commands_apply_to_edited_range() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 6, true).unwrap(); // "Hello " - for (cmd, p) in [ - ("text.underline", json!({})), - ("text.italic", json!({})), - ("text.sizeUp", json!({})), - ("text.trackingUp", json!({})), - ("text.lineHeightUp", json!({})), - ("text.caseSmallCaps", json!({})), - ("text.link", json!({"url": "https://example.com"})), - ] { - s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); - } - let t = data(&s, id); - let (a, b) = (t.style_at(0), t.style_at(8)); - assert_eq!(a.decoration, TextDecoration::Underline); - assert!(a.italic() && !b.italic()); - assert_eq!(a.font_size, 25.0); - assert_eq!(b.font_size, 24.0); - assert_eq!(a.case, TextCase::SmallCaps); - assert_eq!(a.link.as_deref(), Some("https://example.com")); - assert!(b.link.is_none()); - // text.style with an explicit range (what the panel sends). - s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(8).font_size, 40.0); - assert_eq!(t.style_at(8).case, TextCase::Upper); - // Paragraph commands hit the edited node. - s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); - assert_eq!(data(&s, id).list, ListStyle::Numbered); - // Every Text-menu command runs while editing. - for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { - let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; - if crate::find_command(item.id).is_some() { - s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); - } - } - end(&mut s).unwrap(); - } - - #[test] - fn paragraph_and_feature_commands_on_nodes() { - let (mut s, id) = setup("Hello"); - let ids = json!([id]); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); - assert_eq!(data(&s, id).features.get("liga"), Some(&false)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); - let f = data(&s, id).features; - assert_eq!(f.get("liga"), None); - assert_eq!(f.get("ss01"), Some(&true)); - assert_eq!(f.get("tnum"), Some(&true)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); - s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); - s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); - s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); - s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); - s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); - let t = data(&s, id); - assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); - assert_eq!(t.auto_resize, TextAutoResize::Truncate); - assert_eq!(t.style.decoration, TextDecoration::Strikethrough); - assert_eq!(t.style.case, TextCase::SmallCaps); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); - assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); - assert_eq!(data(&s, id).paragraph_indent, 4.0); - } - - fn preedit_state(s: &Session) -> Option { - s.ds().unwrap().text_edit.clone().unwrap().preedit - } - - #[test] - fn ime_composition_is_shown_but_not_in_the_document_until_committed() { - let (mut s, id) = setup("Hello world"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - let doc_before = s.ds().unwrap().doc.clone(); - s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); - assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); - assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); - let (shown, range, cursor) = preedit_doc(&s).unwrap(); - assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); - assert_eq!((range, cursor), ((5, 7), 6)); - let info = s.execute("text.edit.selection", &json!({})).unwrap(); - assert_eq!(info["preedit"]["text"], "にほ"); - // Updating the composition replaces it; an empty one cancels it. - preedit(&mut s, "にほん", None).unwrap(); - assert_eq!(preedit_doc(&s).unwrap().2, 8); - preedit(&mut s, "", None).unwrap(); - assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello world"); - // Commit puts the final text at the caret. - preedit(&mut s, "にほん", None).unwrap(); - s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本 world"); - assert_eq!(sel(&s), (7, 7)); - // Composing over a selection replaces it; moving the caret abandons the composition. - set_caret(&mut s, 7, false).unwrap(); - set_caret(&mut s, 13, true).unwrap(); - preedit(&mut s, "x", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - set_caret(&mut s, 0, false).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本"); - // Ending the edit drops an uncommitted composition; the session stays one undo step. - preedit(&mut s, "zz", None).unwrap(); - end(&mut s).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - // Not editing: an error, not a crash. - assert!(preedit(&mut s, "a", None).is_err()); - } - - #[test] - fn variable_font_axes_as_commands_and_props() { - let (mut s, id) = setup("Hello world"); - let ids = json!([id]); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); - let st = data(&s, id).style; - assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); - assert_eq!(st.weight(), 550); - // null removes one axis; replace clears the rest. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); - assert_eq!(data(&s, id).style.variations.len(), 1); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); - // Undo restores the previous values. - s.undo().unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); - // Junk is rejected. - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); - // node.set props (whole text or a range) replace the custom values. - s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); - assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); - s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); - assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); - // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); - s.execute("text.bold", &json!({"ids": ids})).unwrap(); - let st = data(&s, id).style; - assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); - assert!(!st.variations.contains_key("wght")); - assert_eq!(st.variations.get("wdth"), Some(&90.0)); - // While editing, the command styles the selected range only. - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); - assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); - // The axes query answers without fonts too. - let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); - assert!(r.is_null() || r["axes"].is_array()); - } - - /// No text command panics on junk parameters while a text is being edited. - #[test] - fn text_commands_survive_junk_while_editing() { - let junk = [ - json!({}), - json!(null), - json!({"index": -5, "features": {"": 1}}), - json!({"index": 1e300, "style": 3, "case": null}), - json!([1]), - json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), - json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), - ]; - // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { - for j in &junk { - let (mut s, id) = setup("Hi there\nyou"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { - panic!("{cmd} panicked on {j}: {msg}"); - } - } - } - } - - /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed - /// selection reversed. - #[test] - fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { - let (mut s, id) = setup("café😀e\u{301}x"); - begin(&mut s, id, Some(8), false).unwrap(); - set_caret(&mut s, 8, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "café😀e\u{301}x"); - assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); - assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); - assert_eq!(ime.owner.node, id); - assert_eq!(ime.owner.document, s.ds().unwrap().uid); - } - - /// Asserts an active composition is excluded from surrounding text and an empty text layer - /// reports an available, empty payload. - #[test] - fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - preedit(&mut s, "にほ", Some(1)).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "Hi", "the composition is not committed text"); - assert_eq!((ime.cursor, ime.anchor), (2, 2)); - end(&mut s).unwrap(); - - let (mut s, id) = setup(""); - begin(&mut s, id, Some(0), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); - } - - /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized - /// selection is describable, and one byte more is `SelectionTooWide`. - #[test] - fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { - let long = "a".repeat(5000); - let (mut s, id) = setup(&long); - begin(&mut s, id, Some(5000), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); - assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); - - // Exactly the budget is still describable; one more byte is not. - set_caret(&mut s, 1000, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), 4000); - assert_eq!((ime.cursor, ime.anchor), (4000, 0)); - set_caret(&mut s, 999, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); - } - - /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new - /// edit session changes the generation. - #[test] - fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(0), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - insert(&mut s, "x").unwrap(); - set_caret(&mut s, 2, false).unwrap(); - assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); - end(&mut s).unwrap(); - begin(&mut s, id, Some(0), false).unwrap(); - let replaced = ime_surrounding(&s).unwrap().owner; - assert_eq!(replaced.node, id); - assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); - } - - /// Asserts byte deletion around a reversed selection removes whole characters on both - /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. - #[test] - fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(4), false).unwrap(); - set_caret(&mut s, 4, false).unwrap(); - set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - // "b" (whole character before the selection) and "c" (whole character after it) go; the - // selected "你好" stays for the paired commit, and the reversed direction is preserved. - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "你好"); - assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed - assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); - assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); - } - - /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an - /// active composition and the revision untouched. - #[test] - fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 4, true).unwrap(); // "你好" - assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); - set_caret(&mut s, 0, false).unwrap(); - // A composition in progress survives a delete that finds nothing to remove. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); - assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision); - } - - /// Asserts a text layer in another document has a different IME owner. - #[test] - fn ime_owner_identity_changes_with_the_document() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - end(&mut s).unwrap(); - s.execute("file.new", &json!({"name": "Second"})).unwrap(); - let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); - let other = crate::params::id_of(&r["id"]).unwrap(); - begin(&mut s, other, Some(5), false).unwrap(); - let second = ime_surrounding(&s).unwrap().owner; - assert_ne!(second.document, first.document, "another document is another IME owner"); - assert_eq!(second.node, other); - } - - /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports - /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. - #[test] - fn ime_commit_reports_list_autoformat_as_an_application_transformation() { - let (mut s, id) = setup("-"); - begin(&mut s, id, Some(1), false).unwrap(); - assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, ""); - assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); - assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } - - /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, - /// and bare newline/CR commits are ignored while a live composition remains. - #[test] - fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - // CRLF is inserted as LF, but the editor changed the commit: application-origin. - assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Control characters are dropped; the commit itself is a no-op. - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. - // (It also leaves a live composition alone, unlike the non-ignored commit below.) - preedit(&mut s, "x", None).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); - assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); - preedit(&mut s, "", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb"); - insert(&mut s, "\n").unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb\n"); - } - - /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision - /// once), inserts nothing, and is a harmless no-op when no composition is active. - #[test] - fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(5), false).unwrap(); - preedit(&mut s, "にほ", None).unwrap(); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); - assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); - assert!(preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); - - // A control-only commit normalises to empty and ends the composition too. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello"); - - // With no composition (and not even while editing) an empty commit stays a harmless no-op. - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision); - end(&mut s).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } -} +mod tests; diff --git a/crates/engine/src/canvas/textedit/tests.rs b/crates/engine/src/canvas/textedit/tests.rs new file mode 100644 index 000000000..fecca5808 --- /dev/null +++ b/crates/engine/src/canvas/textedit/tests.rs @@ -0,0 +1,571 @@ +use super::*; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; +use serde_json::json; + +fn setup(text: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); + let id = crate::params::id_of(&r["id"]).unwrap(); + (s, id) +} + +fn data(s: &Session, id: NodeId) -> TextData { + s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() +} + +fn sel(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +#[test] +fn whole_editing_session_is_one_undo_step() { + let (mut s, id) = setup("Hello"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + insert(&mut s, " big").unwrap(); + insert(&mut s, " world").unwrap(); + key(&mut s, "Backspace", false, false, false).unwrap(); + // Styling a range mid-session joins the same step. + set_caret(&mut s, 0, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.bold", &json!({})).unwrap(); + s.execute("text.alignCenter", &json!({})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello big worl"); + assert_eq!(t.style_at(0).font_style, "Bold"); + assert_eq!(t.style_at(6).font_style, "Regular"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + s.undo().unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello"); + assert!(t.runs.is_empty()); + assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); +} + +#[test] +fn typing_replaces_selection_and_paste_is_plain() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, None, false).unwrap(); + assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); + select_word(&mut s, 7).unwrap(); + assert_eq!(sel(&s), (6, 11)); + insert(&mut s, "there").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there"); + assert_eq!(sel(&s), (11, 11)); + // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. + insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + insert(&mut s, "\u{1}").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + // Select the line (paragraph) then type over it. + select_line(&mut s, 0).unwrap(); + assert_eq!(sel(&s), (0, 11)); + insert(&mut s, "Hi").unwrap(); + assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); + assert_eq!(selected_text(&s).as_deref(), Some("")); + end(&mut s).unwrap(); +} + +#[test] +fn word_selection_classes() { + let (mut s, id) = setup("foo, bar_baz!\nnext"); + begin(&mut s, id, Some(0), false).unwrap(); + select_word(&mut s, 1).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Right after a word's last char selects the word. + select_word(&mut s, 13).unwrap(); + assert_eq!(sel(&s), (6, 13)); + // A run of spaces. + select_word(&mut s, 4).unwrap(); + assert_eq!(sel(&s), (4, 6)); + select_line(&mut s, 16).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_line(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_word(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + set_caret(&mut s, 999, false).unwrap(); + assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); +} + +#[test] +fn keyboard_navigation_and_extension() { + let (mut s, id) = setup("one two\nthree"); + begin(&mut s, id, Some(0), false).unwrap(); + key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends + assert_eq!(sel(&s), (3, 7)); + key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end + assert_eq!(sel(&s), (7, 7)); + key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end + assert_eq!(sel(&s), (7, 13)); + key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start + assert_eq!(sel(&s), (0, 0)); + key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward + assert_eq!(data(&s, id).characters, " two\nthree"); + key(&mut s, "SelectAll", false, false, false).unwrap(); + assert_eq!(sel(&s), (0, 10)); + end(&mut s).unwrap(); +} + +#[test] +fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); +} + +fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + e.shift = shift; + e +} + +/// Asserts double-clicking a text layer selects all text, a second double-click selects the +/// word without changing the editing session, and drag and Shift+click extend the +/// selection. +#[test] +fn pointer_double_click_drag_and_shift_click() { + let (mut s, id) = setup("Hello world"); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; // no fonts + } + let y = l.lines[0].top + l.lines[0].height / 2.0; + let x_of = |i: usize| l.caret(i).0; + // Double-click inside "world" edits with all the text selected; another one there + // selects the word. + let mid = (x_of(7) + x_of(8)) / 2.0; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); + // Drag-select from the start of "Hello" to the middle. + s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 0)); + s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Shift+click extends. + s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); + s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); + assert_eq!(sel(&s), (0, 9)); + insert(&mut s, "J").unwrap(); + assert_eq!(data(&s, id).characters, "Jld"); +} + +#[test] +fn text_commands_apply_to_edited_range() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 6, true).unwrap(); // "Hello " + for (cmd, p) in [ + ("text.underline", json!({})), + ("text.italic", json!({})), + ("text.sizeUp", json!({})), + ("text.trackingUp", json!({})), + ("text.lineHeightUp", json!({})), + ("text.caseSmallCaps", json!({})), + ("text.link", json!({"url": "https://example.com"})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + } + let t = data(&s, id); + let (a, b) = (t.style_at(0), t.style_at(8)); + assert_eq!(a.decoration, TextDecoration::Underline); + assert!(a.italic() && !b.italic()); + assert_eq!(a.font_size, 25.0); + assert_eq!(b.font_size, 24.0); + assert_eq!(a.case, TextCase::SmallCaps); + assert_eq!(a.link.as_deref(), Some("https://example.com")); + assert!(b.link.is_none()); + // text.style with an explicit range (what the panel sends). + s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(8).font_size, 40.0); + assert_eq!(t.style_at(8).case, TextCase::Upper); + // Paragraph commands hit the edited node. + s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); + assert_eq!(data(&s, id).list, ListStyle::Numbered); + // Every Text-menu command runs while editing. + for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { + let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; + if crate::find_command(item.id).is_some() { + s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); + } + } + end(&mut s).unwrap(); +} + +#[test] +fn paragraph_and_feature_commands_on_nodes() { + let (mut s, id) = setup("Hello"); + let ids = json!([id]); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); + assert_eq!(data(&s, id).features.get("liga"), Some(&false)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); + let f = data(&s, id).features; + assert_eq!(f.get("liga"), None); + assert_eq!(f.get("ss01"), Some(&true)); + assert_eq!(f.get("tnum"), Some(&true)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); + s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); + s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); + s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); + s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); + s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); + let t = data(&s, id); + assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); + assert_eq!(t.auto_resize, TextAutoResize::Truncate); + assert_eq!(t.style.decoration, TextDecoration::Strikethrough); + assert_eq!(t.style.case, TextCase::SmallCaps); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); + assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); + assert_eq!(data(&s, id).paragraph_indent, 4.0); +} + +fn preedit_state(s: &Session) -> Option { + s.ds().unwrap().text_edit.clone().unwrap().preedit +} + +#[test] +fn ime_composition_is_shown_but_not_in_the_document_until_committed() { + let (mut s, id) = setup("Hello world"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + let doc_before = s.ds().unwrap().doc.clone(); + s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); + assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); + assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); + let (shown, range, cursor) = preedit_doc(&s).unwrap(); + assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); + assert_eq!((range, cursor), ((5, 7), 6)); + let info = s.execute("text.edit.selection", &json!({})).unwrap(); + assert_eq!(info["preedit"]["text"], "にほ"); + // Updating the composition replaces it; an empty one cancels it. + preedit(&mut s, "にほん", None).unwrap(); + assert_eq!(preedit_doc(&s).unwrap().2, 8); + preedit(&mut s, "", None).unwrap(); + assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello world"); + // Commit puts the final text at the caret. + preedit(&mut s, "にほん", None).unwrap(); + s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本 world"); + assert_eq!(sel(&s), (7, 7)); + // Composing over a selection replaces it; moving the caret abandons the composition. + set_caret(&mut s, 7, false).unwrap(); + set_caret(&mut s, 13, true).unwrap(); + preedit(&mut s, "x", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + set_caret(&mut s, 0, false).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本"); + // Ending the edit drops an uncommitted composition; the session stays one undo step. + preedit(&mut s, "zz", None).unwrap(); + end(&mut s).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + // Not editing: an error, not a crash. + assert!(preedit(&mut s, "a", None).is_err()); +} + +#[test] +fn variable_font_axes_as_commands_and_props() { + let (mut s, id) = setup("Hello world"); + let ids = json!([id]); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); + let st = data(&s, id).style; + assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); + assert_eq!(st.weight(), 550); + // null removes one axis; replace clears the rest. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); + assert_eq!(data(&s, id).style.variations.len(), 1); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); + // Undo restores the previous values. + s.undo().unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); + // Junk is rejected. + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); + // node.set props (whole text or a range) replace the custom values. + s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); + assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); + s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); + assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); + // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); + s.execute("text.bold", &json!({"ids": ids})).unwrap(); + let st = data(&s, id).style; + assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); + assert!(!st.variations.contains_key("wght")); + assert_eq!(st.variations.get("wdth"), Some(&90.0)); + // While editing, the command styles the selected range only. + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); + assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); + // The axes query answers without fonts too. + let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); + assert!(r.is_null() || r["axes"].is_array()); +} + +/// No text command panics on junk parameters while a text is being edited. +#[test] +fn text_commands_survive_junk_while_editing() { + let junk = [ + json!({}), + json!(null), + json!({"index": -5, "features": {"": 1}}), + json!({"index": 1e300, "style": 3, "case": null}), + json!([1]), + json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), + json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), + ]; + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { + for j in &junk { + let (mut s, id) = setup("Hi there\nyou"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed +/// selection reversed. +#[test] +fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); +} + +/// Asserts an active composition is excluded from surrounding text and an empty text layer +/// reports an available, empty payload. +#[test] +fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); +} + +/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized +/// selection is describable, and one byte more is `SelectionTooWide`. +#[test] +fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); +} + +/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new +/// edit session changes the generation. +#[test] +fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); +} + +/// Asserts byte deletion around a reversed selection removes whole characters on both +/// sides, keeps the selected text and direction, and a final no-op leaves the text alone. +#[test] +fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); +} + +/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an +/// active composition and the revision untouched. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); +} + +/// Asserts a text layer in another document has a different IME owner. +#[test] +fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); +} + +/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports +/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. +#[test] +fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} + +/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, +/// and bare newline/CR commits are ignored while a live composition remains. +#[test] +fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); +} + +/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision +/// once), inserts nothing, and is a harmless no-op when no composition is active. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/canvas/textpreview.rs similarity index 100% rename from crates/engine/src/textpreview.rs rename to crates/engine/src/canvas/textpreview.rs diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs new file mode 100644 index 000000000..fce14f7c9 --- /dev/null +++ b/crates/engine/src/canvas/tools.rs @@ -0,0 +1,578 @@ +//! Pointer tools. The UI (or an agent through the control channel) feeds [`PointerEvent`]s in page +//! coordinates; tools turn gestures into document edits through interactions (one undo step per +//! gesture) and publish overlay hints (marquee, guides, size label, cursor) for the canvas. + +use std::collections::BTreeMap; + +use figcraft_color::{ImageScaleMode, PaintKind}; +use figcraft_doc::geometry::select_target; +use figcraft_doc::*; +use figcraft_geom::{Affine, Point, Rect, Vec2}; +use serde::Serialize; +use serde_json::json; + +use crate::arc_handles::{ArcHandle, ArcHint}; +use crate::cmd::arrange::move_world; +use crate::cmd::create::frame_at; +use crate::cmd::edit::roots; +use crate::shape_handles::{ShapeHandle, ShapeHint}; +use crate::snap::{Guide, Targets}; +use crate::{Result, Session, textedit}; + +mod click; +mod drag; +mod draw; +mod handles; +mod keys; +mod pen; +mod pointer; + +use handles::DRAG_SLOP_PX; +#[cfg(test)] +pub(crate) use handles::scale_factor; +pub use handles::{ + PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, + selection_box, selection_frame, +}; +pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +#[cfg(test)] +pub(crate) use pen::pressure_profile; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum ToolId { + #[default] + Move, + Scale, + Hand, + Frame, + Section, + Slice, + Rectangle, + Line, + Arrow, + Ellipse, + Polygon, + Star, + Pen, + Pencil, + Text, + TextOnPath, + Comment, + Eyedropper, + ShapeBuilder, + // Board tools. + Sticky, + BoardShape, + Connector, + Marker, + Highlighter, + Eraser, + Stamp, +} + +impl ToolId { + pub const ALL: [ToolId; 26] = [ + ToolId::Move, + ToolId::Scale, + ToolId::Hand, + ToolId::Frame, + ToolId::Section, + ToolId::Slice, + ToolId::Rectangle, + ToolId::Line, + ToolId::Arrow, + ToolId::Ellipse, + ToolId::Polygon, + ToolId::Star, + ToolId::Pen, + ToolId::Pencil, + ToolId::Text, + ToolId::TextOnPath, + ToolId::Comment, + ToolId::Eyedropper, + ToolId::ShapeBuilder, + ToolId::Sticky, + ToolId::BoardShape, + ToolId::Connector, + ToolId::Marker, + ToolId::Highlighter, + ToolId::Eraser, + ToolId::Stamp, + ]; + pub fn id(self) -> &'static str { + match self { + ToolId::Move => "move", + ToolId::Scale => "scale", + ToolId::Hand => "hand", + ToolId::Frame => "frame", + ToolId::Section => "section", + ToolId::Slice => "slice", + ToolId::Rectangle => "rectangle", + ToolId::Line => "line", + ToolId::Arrow => "arrow", + ToolId::Ellipse => "ellipse", + ToolId::Polygon => "polygon", + ToolId::Star => "star", + ToolId::Pen => "pen", + ToolId::Pencil => "pencil", + ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", + ToolId::Comment => "comment", + ToolId::Eyedropper => "eyedropper", + ToolId::ShapeBuilder => "shapeBuilder", + ToolId::Sticky => "sticky", + ToolId::BoardShape => "shape", + ToolId::Connector => "connector", + ToolId::Marker => "marker", + ToolId::Highlighter => "highlighter", + ToolId::Eraser => "eraser", + ToolId::Stamp => "stamp", + } + } + pub fn from_id(s: &str) -> Option { + ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + ToolId::Move => "Move", + ToolId::Scale => "Scale", + ToolId::Hand => "Hand tool", + ToolId::Frame => "Frame", + ToolId::Section => "Section", + ToolId::Slice => "Slice", + ToolId::Rectangle => "Rectangle", + ToolId::Line => "Line", + ToolId::Arrow => "Arrow", + ToolId::Ellipse => "Ellipse", + ToolId::Polygon => "Polygon", + ToolId::Star => "Star", + ToolId::Pen => "Pen", + ToolId::Pencil => "Pencil", + ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", + ToolId::Comment => "Comment", + ToolId::Eyedropper => "Pick color", + ToolId::ShapeBuilder => "Shape builder", + ToolId::Sticky => "Sticky note", + ToolId::BoardShape => "Shapes", + ToolId::Connector => "Connector", + ToolId::Marker => "Marker", + ToolId::Highlighter => "Highlighter", + ToolId::Eraser => "Eraser", + ToolId::Stamp => "Stamp", + } + } + + /// The tools of a board's toolbar (a design file's toolbar has the others). + pub fn on_boards(self) -> bool { + matches!( + self, + ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp + ) + } + /// The tool's key (from [`crate::keymap`]; empty when it has none). + pub fn shortcut(self) -> &'static str { + let id = self.id(); + crate::keymap::KEYS + .iter() + .find(|b| if self == ToolId::Eyedropper { b.id == "edit.pickColor" } else { b.id.strip_prefix("tool.") == Some(id) }) + .and_then(|b| b.keys(crate::keymap::Platform::current()).first().copied()) + .unwrap_or("") + } + + /// The size a click (or Enter, `tool.place`) makes with a shape or frame tool. + pub fn click_size(self) -> (f64, f64) { + match self { + ToolId::Line | ToolId::Arrow => (100.0, 0.0), + ToolId::Section => (400.0, 300.0), + _ => (100.0, 100.0), + } + } + + pub fn draws_shape(self) -> bool { + matches!( + self, + ToolId::Frame + | ToolId::Section + | ToolId::Slice + | ToolId::Rectangle + | ToolId::Ellipse + | ToolId::Polygon + | ToolId::Star + | ToolId::Line + | ToolId::Arrow + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum PointerKind { + Down, + Move, + Up, + /// Pointer moved without a button. + Hover, + DoubleClick, +} + +/// A pointer event in page coordinates (and screen coordinates for panning). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PointerEvent { + pub kind: PointerKind, + pub x: f64, + pub y: f64, + /// Screen position (logical pixels). + pub sx: f64, + pub sy: f64, + pub shift: bool, + pub alt: bool, + /// Cmd on macOS, Ctrl elsewhere. + pub cmd: bool, + pub ctrl: bool, + /// Screen pixels per page unit. + pub zoom: f64, + /// Pen pressure, 0..1, where the device reports it (`None`: a mouse, or a platform that doesn't). + pub pressure: Option, +} + +/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. +fn within_slop(ev: &PointerEvent, start: Point) -> bool { + (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX +} + +impl PointerEvent { + pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self { + Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None } + } + pub(crate) fn p(&self) -> Point { + Point::new(self.x, self.y) + } + fn tol(&self) -> f64 { + 1.0 / self.zoom.max(1e-6) + } +} + +/// Cursor hint for the UI. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Default)] +pub enum Cursor { + #[default] + Default, + Crosshair, + Move, + /// Resize along an angle (degrees, 0 = horizontal). + Resize(f64), + /// Rotate, turned to the corner it is near: the direction from the box's centre to the corner + /// (while rotating: to the pointer), in degrees, 0 = right and 90 = down. + Rotate(f64), + Grab, + Grabbing, + Text, + Pen(PenCursor), + /// A drag here makes a copy (Alt). + Copy, + /// Over a clickable part of a widget. + Pointer, + Eyedropper, + Comment, + /// What the gesture is doing will be undone when it ends (an edit only a main component can + /// make). + NotAllowed, +} + +/// What a click with the pen would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum PenCursor { + /// A new point. + #[default] + New, + /// On a point: closes the path, connects to the point or goes on from it. + Close, + /// On a segment: adds a point there. + Add, + /// Bends the segment or the point's handles (the Bend tool; Cmd/Ctrl in vector edit mode). + Bend, +} + +impl ToolId { + /// The tool's cursor over empty canvas. + pub fn cursor(self) -> Cursor { + match self { + ToolId::Hand => Cursor::Grab, + ToolId::Pen => Cursor::Pen(PenCursor::New), + ToolId::Comment => Cursor::Comment, + ToolId::Eyedropper => Cursor::Eyedropper, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, + // The other tools make something where they are pressed: shapes, frames, slices, pencil + // strokes, a new text (over a text the Text tool shows the I-beam instead). + _ => Cursor::Crosshair, + } + } +} + +/// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the +/// rotation zones outside the corners. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum Handle { + Corner(u8), + Edge(u8), + Rotate(u8), +} + +/// What the canvas should draw on top of the scene. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Overlay { + pub marquee: Option<[f64; 4]>, + pub guides: Vec, + pub cursor: Cursor, + /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. + pub pen: Vec<[f64; 2]>, + pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, + /// Frame/section the pointer is drawing or dropping into (highlighted). + pub drop_target: Option, + /// Auto layout insertion indicator (x0, y0, x1, y1). + pub insert_line: Option<[f64; 4]>, + /// The instance an Alt-drop of a component from the Assets panel would swap (highlighted). + pub swap_target: Option, + /// Auto layout grid cell a drag would drop into (x0, y0, x1, y1). + pub drop_cell: Option<[f64; 4]>, + /// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the + /// paint bucket would fill (page-space polygons). + pub vector_hover: Option, + /// The edited layer `vector_hover` is on (several layers can be in edit mode). + pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, + /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. + pub width_hover: Option, + pub lasso: Vec<[f64; 2]>, + /// The eraser brush under the pointer (x, y, radius in page units). + pub brush: Option<[f64; 3]>, + pub face: Vec>, + /// Shape builder: the regions under the pointer or collected by the drag (indexes into + /// [`crate::shapebuilder::current`]) and whether releasing removes them. + pub builder_regions: Vec, + pub builder_remove: bool, + /// Measuring (Alt): the box of the layer or frame measured to (x0, y0, x1, y1), outlined. + pub measure_box: Option<[f64; 4]>, + /// Scale tool drag: the factor so far and the point that stays put. + pub scale: Option, + /// The selected ellipse's arc handle under the pointer, and the readout while one is dragged + /// (see [`crate::arc_handles`]). + pub arc_hover: Option, + pub arc: Option, + /// The selected shape's corner radius or star ratio handle under the pointer, and the readout + /// while one is dragged (see [`crate::shape_handles`]). + pub shape_hover: Option, + pub shape: Option, +} + +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + +/// The Scale tool's live readout while dragging a handle. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct ScaleHint { + pub factor: f64, + pub pivot: [f64; 2], +} + +#[derive(Clone, Debug)] +pub(crate) struct ResizeStart { + /// Node id → (world transform, w, h) at the start. + nodes: Vec<(NodeId, Affine, f64, f64)>, + /// The handle box: page transform of its top-left corner (axes along the box) and its size. + frame: (Affine, f64, f64), + /// One layer (resized in place) rather than several (mapped from the old box to the new). + single: bool, + handle: Handle, +} + +#[derive(Clone, Debug, Default)] +pub(crate) enum Gesture { + #[default] + None, + Marquee { + start: Point, + /// Shift: toggle what it touches in the selection it started from (`base`). + additive: bool, + base: Vec, + within: Option, + }, + Move { + start: Point, + ids: Vec, + targets: Targets, + duplicate: bool, + moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, + }, + Resize { + start: Point, + r: Box, + scale_tool: bool, + moved: bool, + }, + Rotate { + center: Point, + start_angle: f64, + ids: Vec, + }, + Draw { + start: Point, + id: Option, + parent: NodeId, + targets: Targets, + }, + Pan { + sx: f64, + sy: f64, + cx: f64, + cy: f64, + }, + Pencil { + points: Vec, + /// The pen pressure at each point, where the device reports it. + pressure: Vec>, + }, + TextSelect { + id: NodeId, + }, + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). + TextBox { + id: NodeId, + start: Point, + line: f64, + }, + PenDrag { + anchor: Point, + }, + /// Vector edit mode gestures (handled in `vectoredit`). + Vector(crate::vectoredit::VGesture), + /// Dragging an ellipse's arc handle: the arc so far (see [`crate::arc_handles`]). + Arc { + id: NodeId, + handle: ArcHandle, + arc: crate::arc_handles::Arc, + }, + /// Dragging a shape's corner radius or star ratio handle (see [`crate::shape_handles`]). + Shape { + id: NodeId, + handle: ShapeHandle, + }, + /// Dragging the rotation origin handle (see [`crate::rotation_origin`]). + Origin, + /// The connector tool: dragging from `from` (pressed at `start`). + Connect { + from: figcraft_doc::End, + start: Point, + }, + /// Dragging the selected connector `id`'s end (`from`: its start, pressed at `start`) to + /// attach it elsewhere. + Reattach { + id: NodeId, + from: bool, + start: Point, + }, + /// The eraser: the strokes it took so far. + Erase { + gone: Vec, + }, + /// Shape builder: regions collected so far (handled in `shapebuilder`). + ShapeBuild { + picked: Vec, + remove: bool, + start: Point, + last: Point, + }, +} + +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + +/// The pen tool's in-progress path (page coordinates). +#[derive(Clone, Debug, Default)] +pub struct PenPath { + pub id: Option, + pub parent: Option, + /// Anchors with (in, out) handle offsets. + pub points: Vec<(Point, Vec2, Vec2)>, + pub closed: bool, + /// The path ends connected to this anchor of its own (a vector network). + pub join: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ToolState { + pub tool: ToolId, + pub(crate) gesture: Gesture, + pub overlay: Overlay, + pub pen: Option, + /// Tool to return to after a temporary tool (space → hand). + pub temp_from: Option, + /// Last click (for double-click detection by agents that only send downs). + pub last_hover: Option, + /// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click + /// removes; Alt merges). + pub shape_builder: Option>, + pub builder_remove: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, + /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). + pub(crate) pen_targets: Option, + /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the + /// selection box that stays put (`tool.scaleAnchor`). + pub scale_anchor: (u8, u8), + /// The last drag move handled (a held button repeats it every frame). + pub(crate) last_move: Option, + /// The name labels of top-level frames and sections as the canvas last drew them (each label's + /// corners on the page, turned with its frame): the Move and Scale tools treat a press or + /// hover on one as on its frame. + pub labels: Vec<(NodeId, [Point; 4])>, + /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). + pub pencil_brush: Option, + /// The board tools' colours and choices. + pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, +} + +impl ToolState { + pub fn busy(&self) -> bool { + !matches!(self.gesture, Gesture::None) + } +} diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs new file mode 100644 index 000000000..8f21aaca8 --- /dev/null +++ b/crates/engine/src/canvas/tools/click.rs @@ -0,0 +1,344 @@ +use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; +use super::*; + +impl Session { + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + if self.tool.tool == ToolId::Pen { + return self.pen_finish(); + } + let edits = !self.tool.dev_mode; + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } + // Quick clicks on a widget's clickable part are clicks, one by one. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { + self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; + self.cancel_interaction()?; + } + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the + // pointer (an open path or a line has no inside: only its stroke counts). + if edits + && let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { + return Ok(()); + } + let target = select_target(&doc, leaf, &sel, true); + // Text: edit at the click. + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); + let idx = self.text_index_at(target, ev.p())?; + if editing { + textedit::select_word(self, idx)?; + } else { + textedit::begin(self, target, Some(idx), false)?; + textedit::key(self, "SelectAll", false, false, false)?; + } + return Ok(()); + } + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); + } + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); + if deeper != target || !sel.contains(&deeper) { + self.set_selection(vec![deeper]); + } + Ok(()) + } + + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + pub(super) fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + + /// The char index in text node `id` at page point `p`. + pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { + let doc = self.doc()?; + let n = doc.get(id)?; + let local = doc.world_transform(id).inverse() * p; + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; + Ok(layout.hit(local.x, local.y - dy)) + } + + pub(super) fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, extend)?; + self.tool.gesture = Gesture::TextSelect { id }; + Ok(()) + } + + pub(super) fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + if let Some(te) = ds.text_edit.clone() { + if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Clicking existing text edits it. + if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + { + let idx = self.text_index_at(leaf, ev.p())?; + textedit::begin(self, leaf, Some(idx), false)?; + self.tool.gesture = Gesture::TextSelect { id: leaf }; + return Ok(()); + } + // New text at the click (Figma puts the baseline-ish top-left there). + let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); + let pw = d.world_transform(parent); + let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); + n.transform = pw.inverse() * Affine::translate(at.to_vec2()); + n.name = String::new(); + if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + if let Some((w, h)) = figcraft_text::auto_size(&n) { + n.width = w.max(1.0); + n.height = h; + } + let line = n.height; + d.insert(n, parent, None)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + pub(super) fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + pub(super) fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + + pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; + let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; + let rgba = figcraft_render::to_rgba8(&pix); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); + let sel = ds.selection.clone(); + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { + self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; + } + self.recent_colors.retain(|x| *x != c); + self.recent_colors.insert(0, c); + self.recent_colors.truncate(16); + self.message = Some(format!("Picked #{}", c.to_hex())); + self.set_tool(ToolId::Move); + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs new file mode 100644 index 000000000..0d4066732 --- /dev/null +++ b/crates/engine/src/canvas/tools/drag.rs @@ -0,0 +1,605 @@ +use super::handles::{ + SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, + shape_hint, +}; +use super::*; + +impl Session { + pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + // Clicking outside the text being edited ends editing. + if let Some(te) = ds.text_edit.clone() { + let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); + if inside { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. + let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } + if self.arc_handles_shown() + && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) + && let Some(arc) = crate::arc_handles::arc_of(&doc, id) + { + self.begin_interaction("Arc")?; + self.tool.gesture = Gesture::Arc { id, handle, arc }; + self.tool.overlay.arc = + crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); + return Ok(()); + } + if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) + && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) + { + self.begin_interaction("Corner radius")?; + self.tool.gesture = Gesture::Shape { id, handle }; + self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); + return Ok(()); + } + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { + let ids = roots(&doc, &sel); + if let Handle::Rotate(_) = h { + let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; + let center = self + .ds()? + .moved_origin() + .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); + self.begin_interaction("Rotate")?; + self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + return Ok(()); + } + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; + let single = ids.len() == 1; + self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, frame, single, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + moved: false, + }; + return Ok(()); + } + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) + }; + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, the frame or canvas behind it, or a layer stacked below the selected layer the + // press is in) drags the selection. In a gap between selected layers, what's there is hit. + let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); + let behind_sel = |t: Option| { + t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) + }; + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; + match target { + Some(target) => { + // Clicking inside the current selection's box keeps it (drag them all). + let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); + let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + sel.clone() + } else if ev.shift && deselect.is_none() { + let mut s2 = sel.clone(); + s2.push(target); + s2 + } else if sel.contains(&target) { + sel.clone() + } else { + vec![target] + }; + self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; + } + None => { + let within = self.marquee_within(ev.p())?; + if !ev.shift { + self.set_selection(vec![]); + } + self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; + } + } + Ok(()) + } + + pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + let res = match &g { + Gesture::None => return self.hover(ev), + Gesture::Marquee { start, additive, base, within } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + // Cmd/Ctrl: the nested layers it touches. + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; + self.set_selection(sel); + Ok(()) + } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), + Gesture::Move { start, ids, targets, duplicate, .. } => { + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let mut guides = Vec::new(); + if snap_on { + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; + guides = g2; + } + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); + } + let ids = ids.clone(); + let dup = *duplicate; + let page = self.page()?; + let mut drop_cell = None; + let mut drop_target = None; + let mut in_flow = false; + // Placed in a row or column: (is it a column, its gap) for the insertion line. + let mut slot_axis: Option<(bool, f64)> = None; + self.preview(|doc, sel| { + let mut moving = ids.clone(); + if dup { + let mut copies = Vec::new(); + for i in &ids { + let parent = doc.get(*i)?.parent.unwrap_or(page); + let idx = doc.index_in_parent(*i).map(|x| x + 1); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); + } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); + moving = copies; + *sel = moving.clone(); + } + let p = ev.p(); + let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); + let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; + let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); + if let (true, Some(mode)) = (flow, mode) { + // Into (or within) an auto layout frame: the layers take the slot (a grid: the + // cell) under the pointer, as the frame shows it now. A frame other than the + // one they come from is outlined too. + let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; + let idx = + if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; + for m in &moving { + if doc.get(*m)?.parent != Some(target) { + doc.reparent(*m, target, None)?; + } + } + if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { + let cl = &mut doc.get_mut(first)?.child_layout; + cl.grid_column = Some(col); + cl.grid_row = Some(row); + drop_cell = Some(rect); + } else if mode != LayoutMode::Grid { + let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); + for (k, m) in moving.iter().enumerate() { + kids.insert((idx + k).min(kids.len()), *m); + } + let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); + doc.get_mut(target)?.children = kids; + slot_axis = Some((mode == LayoutMode::Vertical, gap)); + } + drop_target = (Some(target) != home).then_some(target); + in_flow = true; + return Ok(()); + } + // A free move. Layers dragged out of an auto layout flow leave it (onto the page + // while dragging; the drop puts them in the frame under the pointer). + for m in &moving { + let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flow && in_al { + doc.reparent(*m, page, None)?; + } + move_world(doc, *m, d.x, d.y)?; + } + drop_target = (flow && target != page).then_some(target); + Ok(()) + })?; + let placed = if dup { self.selection() } else { ids }; + // Snapping guides mean nothing while the layout places the layers; with Alt held, + // live distances to the neighbours replace them. + self.tool.overlay.guides = if in_flow { + Vec::new() + } else if ev.alt { + let doc = self.doc()?; + doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() + } else { + guides + }; + // The preview already shows the layers in their slot: the line marks it there, in + // the middle of the gap before them (clear of the selection box). + self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { + let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; + self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) + }); + self.tool.overlay.drop_cell = drop_cell; + self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; + self.tool.gesture = g.clone(); + if let Gesture::Move { moved, .. } = &mut self.tool.gesture { + *moved = true; + } + return Ok(()); + } + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } + return res; + } + Gesture::Rotate { center, start_angle, ids } => { + let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); + if ev.shift { + a = (a / 15.0).round() * 15.0; + } + let (c, ids) = (*center, ids.clone()); + self.preview(|doc, _| { + for i in &ids { + crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; + } + Ok(()) + })?; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; + Ok(()) + } + Gesture::Draw { start, id, parent, targets } => { + let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); + self.tool.gesture = g; + return self.draw_drag(ev, start, id, parent, &targets); + } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } + Gesture::Arc { id, handle, arc } => { + let (id, handle, arc) = (*id, *handle, *arc); + self.tool.gesture = g; + return self.arc_drag(ev, id, handle, arc); + } + Gesture::Shape { id, handle } => { + let (id, handle) = (*id, *handle); + let doc = self.doc()?.clone(); + if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + } + self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); + self.tool.overlay.shape_hover = Some(handle); + Ok(()) + } + Gesture::Pan { sx, sy, cx, cy } => { + let ds = self.ds_mut()?; + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - (ev.sx - sx) / z; + ds.view.cy = cy - (ev.sy - sy) / z; + ds.revision += 1; + Ok(()) + } + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); + if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + pts.push(ev.p()); + pressure.push(ev.pressure); + } + self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + Gesture::TextSelect { id } => { + let id = *id; + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, true)?; + Ok(()) + } + Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } + Gesture::PenDrag { anchor } => { + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; + if let Some(pen) = self.tool.pen.as_mut() + && let Some(last) = pen.points.last_mut() + { + last.2 = out; + last.1 = -out; + } + self.pen_update()?; + Ok(()) + } + }; + self.tool.gesture = g; + res + } + + /// An arc handle drag: the new arc, written through `node.set`'s keys. + fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { + let doc = self.doc()?.clone(); + let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; + let props = crate::arc_handles::props(next); + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + self.tool.gesture = Gesture::Arc { id, handle, arc: next }; + let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); + self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); + self.tool.overlay.arc_hover = Some(handle); + Ok(()) + } + + fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { + let page = self.page()?; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); + let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; + let mut p = ev.p(); + let mut guides = Vec::new(); + if let Some(t) = &targets { + let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } + let q = frame.inverse() * p; + let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + if scale_tool { + return self.scale_drag(ev, r, q, guides); + } + if is_line { + // Line end handles: move the end point freely (length + angle). + let (a, b) = match r.handle { + Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), + _ => (frame * Point::new(0.0, 0.0), p), + }; + let id = ids.first().copied().unwrap_or(NodeId(0)); + self.preview(|doc, _| { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + let len = v.hypot(); + let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + doc.set_world_transform(id, world)?; + figcraft_layout::set_size(doc, id, len, 0.0); + Ok(()) + })?; + self.tool.overlay.guides = guides; + return Ok(()); + } + // Constrain proportions (Shift, or the node's lock). + let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); + let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); + let (flip_x, flip_y) = (x1 < x0, y1 < y0); + let (nx0, ny0) = (x0.min(x1), y0.min(y1)); + let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); + let nodes = r.nodes.clone(); + let single = r.single; + let handle = r.handle; + // What's in a section stays where it is on the page, and so does what's in a frame resized + // with Cmd/Ctrl held (its constraints are ignored). + let keeps = |doc: &Document, id: NodeId| { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) + }; + self.preview(|doc, _| { + if single { + let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + let mut t = *world * Affine::translate((nx0, ny0)); + if flip_x { + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + } + if flip_y { + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + } + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). + let n = doc.get(*id)?; + let crop_images = ev.cmd + && !matches!(n.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } + doc.set_world_transform(*id, t)?; + if kept.is_empty() { + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + } else { + figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); + crate::cmd::props::restore_worlds(doc, &kept)?; + } + let n = doc.get_mut(*id)?; + if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { + n.child_layout.sizing_h = Sizing::Fixed; + } + if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { + n.child_layout.sizing_v = Sizing::Fixed; + } + if let NodeKind::Text(t) = &mut n.kind { + match handle { + Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} + Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, + _ => t.fix_size(true, true), + } + } + return Ok(()); + } + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); + let inv = frame.inverse(); + for (id, world, w, h) in &nodes { + let local = inv * *world; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; + crate::cmd::props::restore_worlds(doc, &kept)?; + } + Ok(()) + })?; + self.tool.overlay.guides = guides; + Ok(()) + } + + /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything + /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and + /// text included, children by the same factor whatever their constraints. + fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { + let (frame, w0, h0) = r.frame; + let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; + let nodes = r.nodes.clone(); + let inv = frame.inverse(); + // A layer's place in the box scales about the pivot; a negative factor turns it over. + let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); + let sign = k.signum(); + self.preview(|doc, _| { + for (id, world, _, _) in &nodes { + let c = (inv * *world).as_coeffs(); + doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; + crate::cmd::arrange::scale_node(doc, *id, k.abs())?; + } + Ok(()) + })?; + let p = frame * pivot; + self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); + self.tool.overlay.guides = guides; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs new file mode 100644 index 000000000..b594ae273 --- /dev/null +++ b/crates/engine/src/canvas/tools/draw.rs @@ -0,0 +1,229 @@ +use super::handles::SNAP_PX; +use super::pen::take_enclosed; +use super::*; + +impl Session { + pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let page = ds.page; + if ds.text_edit.is_some() { + textedit::end(self)?; + } + // Frames drawn inside frames nest; sections only at the top or in sections. + let parent = match self.tool.tool { + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } + _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), + }; + let targets = Targets::gather(&doc, page, &[]); + self.begin_interaction(self.tool.tool.label())?; + self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; + Ok(()) + } + + pub(super) fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { + let tool = self.tool.tool; + let mut p = ev.p(); + let mut guides = Vec::new(); + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { + let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let mut created = id; + let page = self.page()?; + self.preview(|doc, sel| { + let (mut a, mut b) = (start, p); + if pixel { + a = Point::new(a.x.round(), a.y.round()); + b = Point::new(b.x.round(), b.y.round()); + } + let pw = doc.world_transform(parent); + let (kind, base) = match tool { + ToolId::Frame => (NodeKind::Frame, "Frame"), + ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + ToolId::Slice => (NodeKind::Slice, "Slice"), + ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), + ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), + ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), + ToolId::Line => (NodeKind::Line, "Line"), + ToolId::Arrow => (NodeKind::Line, "Arrow"), + _ => return Ok(()), + }; + let name = match tool { + ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), + _ => base.to_string(), + }; + let nid = doc.alloc_id(); + let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; + if tool == ToolId::Arrow { + n.stroke.cap_end = StrokeCap::LineArrow; + } + if matches!(tool, ToolId::Line | ToolId::Arrow) { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + // Alt draws from the centre, as for the other shapes. + let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; + n.width = v.hypot(); + n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + } else { + let mut d = b - a; + if ev.shift { + let m = d.x.abs().max(d.y.abs()); + d = Vec2::new(m * d.x.signum(), m * d.y.signum()); + } + let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; + let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + n.width = r.width(); + n.height = r.height(); + n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); + } + doc.insert(n, parent, None)?; + *sel = vec![nid]; + created = Some(nid); + let _ = page; + Ok(()) + })?; + self.tool.overlay.guides = guides; + self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; + Ok(()) + } + + pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + self.tool.overlay.marquee = None; + self.tool.overlay.guides.clear(); + self.tool.overlay.scale = None; + self.tool.overlay.arc = None; + self.tool.overlay.shape = None; + self.tool.overlay.measure_box = None; + self.tool.overlay.insert_line = None; + self.tool.overlay.drop_cell = None; + let drop_target = self.tool.overlay.drop_target.take(); + match g { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + if moved { + // A free move lands in the frame under the pointer (or the page) where it was + // dropped; layers in an auto layout frame already sit in their slot. + let page = self.page()?; + let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; + let moving = if duplicate { sel } else { ids }; + let doc = self.doc()?.clone(); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let target = drop_target.unwrap_or(page); + let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); + if !in_al && current != Some(target) { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + let d = ds.doc_mut(); + for m in &moving { + // Group, boolean group and instance layers stay put: they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { + continue; + } + // A layer can't go into itself; it stays where it is. + let _ = d.reparent(*m, target, None); + } + crate::normalize(ds.doc_mut(), &before); + } + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. + if self.refused(&moving, true) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } + } + } else { + self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } + } + } + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { + // Layers inside an instance keep their main's size and rotation. + if self.refused(&self.selection(), false) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } + Gesture::Draw { start, id, parent, .. } => { + let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; + if tiny || id.is_none() { + // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). + self.cancel_interaction()?; + self.begin_interaction(self.tool.tool.label())?; + let (w, h) = self.tool.tool.click_size(); + let target = start + Vec2::new(w, h); + let mut fake = ev.clone(); + fake.x = target.x; + fake.y = target.y; + fake.shift = false; + fake.alt = false; + self.tool.gesture = Gesture::None; + let targets = Targets::default(); + self.draw_drag(&fake, start, None, parent, &targets)?; + self.tool.gesture = Gesture::None; + } + if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) + && let Some(drawn) = self.selection().first().copied() + { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + take_enclosed(ds.doc_mut(), drawn)?; + crate::normalize(ds.doc_mut(), &before); + } + self.commit_interaction()?; + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + Gesture::Pan { .. } => { + self.tool.overlay.cursor = Cursor::Grab; + if let Some(t) = self.tool.temp_from.take() { + self.tool.tool = t; + } + } + Gesture::Pencil { mut points, mut pressure } => { + self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } + self.pencil_finish(&points, &pressure)?; + } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} + Gesture::Vector(_) => self.commit_interaction()?, + } + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs new file mode 100644 index 000000000..fd765f66a --- /dev/null +++ b/crates/engine/src/canvas/tools/handles.rs @@ -0,0 +1,426 @@ +use super::*; + +/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost +/// one drawn, if it can be selected (still there, on the page, visible and unlocked). +pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { + labels + .iter() + .rev() + .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) + .map(|(id, _)| *id) +} + +/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). +pub(super) fn in_quad(q: &[Point; 4], p: Point) -> bool { + let (mut left, mut right) = (false, false); + for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { + let side = (*b - *a).cross(p - *a); + if !side.is_finite() { + return false; + } + left |= side < 0.0; + right |= side > 0.0; + } + // On one side of every edge (a point can't be on all four edges of a quad with an area). + left != right +} + +/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, +/// 1 middle, 2 end). +pub fn anchor_of(name: &str) -> Option<(u8, u8)> { + ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) +} + +/// The name of anchor `(column, row)`. +pub fn anchor_name(a: (u8, u8)) -> &'static str { + ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") +} + +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + +const HANDLE_PX: f64 = 5.0; + +const ROTATE_PX: f64 = 18.0; + +/// Snap distance in screen pixels. +pub(crate) const SNAP_PX: f64 = 5.0; + +/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a +/// double-click made with an unsteady hand doesn't drag what it lands on. +pub(super) const DRAG_SLOP_PX: f64 = 3.0; + +/// Screen pixels from a path within which the Text on a path tool takes it. +pub(super) const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +pub(super) fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} + +/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). +pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { + let (f, w, h) = selection_frame(doc, sel)?; + Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) +} + +/// The selection's handle box: the page transform of its top-left corner (axes along the box) and +/// its size. One layer: its own box; several sharing a rotation: a box turned with them; +/// otherwise the page-aligned bounds. +pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { + let ids = roots(doc, sel); + if let [id] = ids.as_slice() { + let n = doc.node(*id)?; + return Some((doc.world_transform(*id), n.width, n.height)); + } + let Some(angle) = shared_rotation(doc, &ids) else { + let b = doc.bounds_of(&ids)?; + return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); + }; + let rot = Affine::rotate(angle); + let inv = rot.inverse(); + let b = ids + .iter() + .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) + .reduce(|a, b| a.union(b))?; + Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) +} + +/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the +/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. +fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { + use std::f64::consts::{FRAC_PI_2, PI}; + const EPS: f64 = 1e-4; + let mut angles = ids.iter().map(|i| { + let c = doc.world_transform(*i).as_coeffs(); + c[1].atan2(c[0]) + }); + let a = angles.next()?; + let same = |b: f64| { + let d = (b - a).rem_euclid(PI); + d.min(PI - d) < EPS + }; + let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; + (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) +} + +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +pub(super) fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. +pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { + return None; + } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } + let corners = selection_box(doc, sel)?; + // Lines (zero height) only get end handles. + let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); + handle_at_corners(&corners, p, zoom, is_line) +} + +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +pub(super) fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + +/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; +/// `is_line` keeps only the two end handles. +pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { + let tol = HANDLE_PX / zoom.max(1e-6); + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; + } + if !is_line { + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; + } + } + // Rotation zones: just outside each corner. + let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); + let rot = ROTATE_PX / zoom.max(1e-6); + for (i, c) in corners.iter().enumerate() { + let d = (*c - p).hypot(); + let outward = (p - *c).dot(*c - center) > 0.0; + if d <= rot && d > tol && outward { + return Some(Handle::Rotate(i as u8)); + } + } + None +} + +pub(super) fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { + selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) +} + +/// The cursor over handle `h` of the box with corners `c`. +pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { + let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); + match h { + Handle::Corner(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } + } + Handle::Edge(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + Cursor::Resize(angle(a, b) + 90.0) + } + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } + } +} + +/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). +pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} + } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, + }; + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); + } + (x0, y0, x1, y1) +} + +/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. +fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { + match h { + Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), + Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), + Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), + Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), + Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), + Handle::Edge(1) => Point::new(w0, h0 / 2.0), + Handle::Edge(2) => Point::new(w0 / 2.0, h0), + Handle::Edge(_) => Point::new(0.0, h0 / 2.0), + } +} + +/// Anchor `(column, row)` of a `w`×`h` box, in its axes. +fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { + Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) +} + +/// The Scale tool's anchor point on the selection's handle box, in page coordinates. +pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { + let (f, w, h) = selection_frame(doc, sel)?; + Some(f * anchor_in_box(anchor, w, h)) +} + +/// The handle across the box from `h`. +fn opposite(h: Handle) -> Handle { + match h { + Handle::Corner(i) => Handle::Corner((i + 2) % 4), + Handle::Edge(i) => Handle::Edge((i + 2) % 4), + Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), + } +} + +/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box +/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the +/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis +/// that moved the most; a negative factor flips. None when the box has no size to scale by. +pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { + const EPS: f64 = 1e-9; + if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { + return None; + } + let h = handle_point(handle, w0, h0); + let anchor_pt = anchor_in_box(anchor, w0, h0); + // The axes the handle moves along. + let (ax_x, ax_y) = match handle { + Handle::Edge(0) | Handle::Edge(2) => (false, true), + Handle::Edge(_) => (true, false), + _ => (true, true), + }; + let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); + let pivot = if from_center { + Point::new(w0 / 2.0, h0 / 2.0) + } else if on_side { + handle_point(opposite(handle), w0, h0) + } else { + anchor_pt + }; + let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); + let kx = ratio(ax_x, q.x, h.x, pivot.x); + let ky = ratio(ax_y, q.y, h.y, pivot.y); + let k = match (kx, ky) { + (Some(a), Some(b)) => { + if a.abs() > b.abs() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => return None, + }; + if !k.is_finite() { + return None; + } + // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. + Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) +} + +/// The readout of a shape handle drag on `id`. +pub(super) fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { + Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) +} + +#[allow(dead_code)] +fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +pub(super) fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs new file mode 100644 index 000000000..591f14795 --- /dev/null +++ b/crates/engine/src/canvas/tools/keys.rs @@ -0,0 +1,174 @@ +use super::*; + +impl Session { + /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point + /// keys, Enter to finish the shape builder). Returns whether the key was used. + pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } + if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return crate::vectoredit::key(self, name, shift, alt, cmd); + } + if !alt && !cmd && self.guide_key(name, shift)? { + return Ok(true); + } + crate::shapebuilder::key(self, name) + } + + /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big + /// nudge), Escape deselects it. + fn guide_key(&mut self, name: &str, shift: bool) -> Result { + let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; + // Backspace is Delete, `Left` is `ArrowLeft`, and so on. + let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); + let page = self.page()?; + let at = |mut p: serde_json::Value| { + if owner != page { + p["frame"] = json!(owner); + } + p + }; + let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; + let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; + let d = match (name, g.vertical) { + ("Delete", _) => { + self.execute("guide.remove", &at(json!({"index": index})))?; + return Ok(true); + } + ("Escape", _) => { + self.execute("guide.select", &json!({}))?; + return Ok(true); + } + ("ArrowLeft", true) | ("ArrowUp", false) => -step, + ("ArrowRight", true) | ("ArrowDown", false) => step, + _ => return Ok(false), + }; + self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; + Ok(true) + } + + /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in + /// outline mode shapes only on their outlines. + pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let focus = self.ds().ok().and_then(crate::DocState::focused); + // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); + } + match focus { + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), + } + } + + /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` + /// (default the middle of the view) and selected. + pub fn place(&mut self, at: Option) -> Result<()> { + let tool = self.tool.tool; + let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); + if !tool.draws_shape() { + return refuse("choose a shape or frame tool first"); + } + if self.tool.busy() { + return refuse("finish the drag first"); + } + let v = &self.ds()?.view; + let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); + let (w, h) = tool.click_size(); + let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); + let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); + if r.is_err() { + self.tool.gesture = Gesture::None; + let _ = self.cancel_interaction(); + } + r + } + + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the + /// focus view, once the focused frame or nothing is selected, it ends the focus view). + pub fn escape(&mut self) -> Result<()> { + // Following someone in a live session: Escape stops that first. + if self.live_unfollow() { + return Ok(()); + } + if self.tool.pen.is_some() { + return self.pen_end(); + } + if self.tool.busy() { + self.tool.gesture = Gesture::None; + self.tool.overlay = Overlay::default(); + return self.cancel_interaction(); + } + if self.end_origin_edit() { + return Ok(()); + } + if self.ds()?.text_edit.is_some() { + return textedit::end(self); + } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::escape(self); + } + if self.tool.tool != ToolId::Move { + self.set_tool(ToolId::Move); + return Ok(()); + } + let sel = self.selection(); + if let Some(f) = self.ds()?.focused() + && (sel.is_empty() || sel == [f]) + { + self.ds_mut()?.focus = None; + return Ok(()); + } + // Escape selects the parent, then nothing. + let doc = self.doc()?; + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + if !parents.is_empty() { + let mut uniq = Vec::new(); + for p in parents { + if !uniq.contains(&p) { + uniq.push(p); + } + } + self.set_selection(uniq); + } else { + self.set_selection(vec![]); + } + Ok(()) + } + + /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor + /// in vector or text edit mode). + pub(super) fn arc_handles_shown(&self) -> bool { + let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); + self.tool.tool == ToolId::Move + && !self.tool.dev_mode + && (arc_drag || !self.tool.busy()) + && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) + } + + /// The selected ellipse's arc handles as the canvas shows them at the current zoom. + pub fn arc_handles(&self) -> Option { + let ds = self.ds().ok()?; + self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() + } + + /// The selected shape's corner radius and star ratio handles as the canvas shows them now. + pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { + let Ok(ds) = self.ds() else { return vec![] }; + if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } + } + + /// Snapshot of tool state for the UI / inspect. + pub fn tool_info(&self) -> serde_json::Value { + let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); + json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) + } +} diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs new file mode 100644 index 000000000..319c11b7b --- /dev/null +++ b/crates/engine/src/canvas/tools/pen.rs @@ -0,0 +1,276 @@ +use super::handles::{PENCIL_STROKE, selected_end_at}; +use super::*; + +/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most +/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) +} + +/// A frame or section drawn around layers takes those of its siblings that lie fully inside it +/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, +/// where the new frame is one more item of the flow. +pub(super) fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { + let Some(n) = doc.node(drawn) else { return Ok(()) }; + let section = matches!(n.kind, NodeKind::Section { .. }); + let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; + if parent.layout.is_some() { + return Ok(()); + } + let inside: Vec = parent + .children + .iter() + .copied() + .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) + .collect(); + for c in inside { + doc.reparent(c, drawn, None)?; + } + Ok(()) +} + +impl Session { + /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first + /// point) counts among the targets. + pub(super) fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { + let (id, extra) = match &self.tool.pen { + Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), + Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), + None => (None, Vec::new()), + }; + self.snap_pen(ev, id, &extra) + } + + pub(super) fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { + self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; + let (doc, page) = { + let ds = self.ds()?; + (ds.doc.clone(), ds.page) + }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + return self.pen_edit(id, Some(v)); + } + let pen = self.tool.pen.get_or_insert_with(PenPath::default); + // Clicking the first anchor closes the path. + if pen.points.len() > 2 + && let Some((first, _, _)) = pen.points.first() + && (*first - p).hypot() * ev.zoom < 8.0 + { + pen.closed = true; + self.pen_update()?; + return self.pen_finish(); + } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } + if pen.parent.is_none() { + pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); + } + let mut p = p; + if ev.shift + && let Some((last, _, _)) = pen.points.last() + { + p = at_45_degrees(*last, p); + } + pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + self.tool.gesture = Gesture::PenDrag { anchor: p }; + self.pen_update() + } + + /// Rebuild the pen's vector node from its anchors. + pub(super) fn pen_update(&mut self) -> Result<()> { + let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; + self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); + if pen.points.len() < 2 { + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + let page = self.page()?; + let parent = pen.parent.unwrap_or(page); + if pen.id.is_none() { + self.begin_interaction("Pen")?; + } + let ds = self.ds_mut()?; + let doc = ds.doc_mut(); + let pw = doc.world_transform(parent); + let inv = pw.inverse(); + // Anchors and handles into the parent's space. + let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); + let mut path = figcraft_geom::BezPath::new(); + for (k, (p, _, _)) in pts.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { + path.curve_to(prev.2, cur.1, *p); + } + } + if pen.closed + && let (Some(last), Some(first)) = (pts.last(), pts.first()) + { + path.curve_to(last.2, first.1, first.0); + path.close_path(); + } + use figcraft_geom::Shape; + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + // Straight segments stay straight. + for s in &mut net.segments { + if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { + s.ta = (0.0, 0.0); + s.tb = (0.0, 0.0); + } + } + if !pen.closed { + net.regions.clear(); + } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } + let id = match pen.id { + Some(id) if doc.contains(id) => id, + _ => { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); + n.fills.clear(); + n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + n.stroke.align = StrokeAlign::Center; + doc.insert(n, parent, None)?; + id + } + }; + let n = doc.get_mut(id)?; + n.kind = NodeKind::Vector { network: net }; + n.width = bb.width(); + n.height = bb.height(); + n.transform = Affine::translate((bb.x0, bb.y0)); + if pen.closed && n.fills.is_empty() { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + } + ds.selection = vec![id]; + ds.revision += 1; + if let Some(p) = self.tool.pen.as_mut() { + p.id = Some(id); + } + Ok(()) + } + + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). + pub fn pen_finish(&mut self) -> Result<()> { + let pen = self.tool.pen.take(); + self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + self.tool.gesture = Gesture::None; + if pen.as_ref().is_some_and(|p| p.id.is_some()) { + self.commit_interaction()?; + } + Ok(()) + } + + pub(super) fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { + if points.len() < 2 { + return Ok(()); + } + // Smooth: fit a curve through the samples. + let mut poly = figcraft_geom::BezPath::new(); + for (k, p) in points.iter().enumerate() { + if k == 0 { + poly.move_to(*p); + } else { + poly.line_to(*p); + } + } + let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); + let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } + let page = self.page()?; + let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); + let pw = self.doc()?.world_transform(parent); + let mut local = fitted.clone(); + local.apply_affine(pw.inverse()); + let _ = d; + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } + self.execute( + "node.create", + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), + )?; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs new file mode 100644 index 000000000..985e33261 --- /dev/null +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -0,0 +1,292 @@ +use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; +use super::*; + +impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + + pub fn set_tool(&mut self, t: ToolId) { + if self.tool.tool == ToolId::Pen && t != ToolId::Pen { + let _ = self.pen_finish(); + } + if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + let _ = crate::vectoredit::end(self); + } + self.tool.tool = t; + self.tool.gesture = Gesture::None; + self.tool.pick_only = false; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; + self.tool.shape_builder = None; + if t == ToolId::ShapeBuilder { + // Regions show straight away, before the pointer moves. + let sel = self.selection(); + let _ = crate::shapebuilder::arrangement(self, &sel); + } + if let Ok(ds) = self.ds_mut() { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { + ds.text_edit = None; + } + ds.revision += 1; + } + } + + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + + /// Feed one pointer event to the current tool. + pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { + return Ok(()); + } + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + return Ok(()); + } + if tool == ToolId::ShapeBuilder { + return self.shape_builder_pointer(ev); + } + match ev.kind { + PointerKind::Hover => self.hover(ev), + PointerKind::Down => match tool { + ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } + // A click on the canvas deselects a ruler guide (the UI selects guides itself). + self.ds_mut()?.guide = None; + self.move_down(ev) + } + ToolId::Hand => { + let v = &self.ds()?.view; + self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; + self.tool.overlay.cursor = Cursor::Grabbing; + Ok(()) + } + ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), + ToolId::Pen => self.pen_down(ev), + ToolId::Pencil => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Comment => { + self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); + Ok(()) + } + ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), + t if t.draws_shape() => self.draw_down(ev), + _ => Ok(()), + }, + PointerKind::Move => self.drag(ev), + PointerKind::Up => self.up(ev), + PointerKind::DoubleClick => self.double_click(ev), + } + } + + /// The rotation origin handle while it shows: a press drags it, a double-click puts it back + /// at the centre. True when the event was the handle's. + fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { + let dragging = matches!(self.tool.gesture, Gesture::Origin); + if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { + return Ok(false); + } + match ev.kind { + PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, + PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, + PointerKind::Up if dragging => self.tool.gesture = Gesture::None, + PointerKind::DoubleClick => self.set_rotation_origin(None)?, + PointerKind::Hover => {} + _ => return Ok(false), + } + self.tool.overlay.cursor = Cursor::Move; + Ok(true) + } + + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { + let tool = self.acting_tool(); + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } + let mut hover = None; + let mut guides = Vec::new(); + let mut measure_box = None; + let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + self.tool.overlay.arc_hover = arc_hover; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; + self.tool.overlay.shape_hover = shape_hover; + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { + cursor = resize_cursor(&doc, &sel, h); + } else { + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } + if ev.alt || self.tool.dev_mode { + use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; + // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the + // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. + let around: Vec = sel.iter().copied().chain(hover).collect(); + let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { + if sel.is_empty() { + return None; + } + let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); + guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) + }); + if let Some(what) = what { + let (g, b) = hover_lines(&doc, page, &sel, what); + guides = g; + measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); + } + } + } + } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } + self.tool.overlay.measure_box = measure_box; + self.tool.overlay.cursor = cursor; + if tool == ToolId::Pen { + // Where a click would land (snapped), with its guides. + let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; + guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; + self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); + } + } + self.tool.overlay.guides = guides; + let ds = self.ds_mut()?; + if ds.hover != hover { + ds.hover = hover; + ds.revision += 1; + } + Ok(()) + } + + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + pub(super) fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } +} diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs similarity index 100% rename from crates/engine/src/vectoredit.rs rename to crates/engine/src/canvas/vectoredit.rs diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs similarity index 100% rename from crates/engine/src/vectoredit/ops.rs rename to crates/engine/src/canvas/vectoredit/ops.rs diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/canvas/vectoredit/point_snap.rs similarity index 100% rename from crates/engine/src/vectoredit/point_snap.rs rename to crates/engine/src/canvas/vectoredit/point_snap.rs diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs similarity index 100% rename from crates/engine/src/vectoredit/pointer.rs rename to crates/engine/src/canvas/vectoredit/pointer.rs diff --git a/crates/engine/src/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs similarity index 100% rename from crates/engine/src/vectoredit/regions.rs rename to crates/engine/src/canvas/vectoredit/regions.rs diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/regions_tests.rs rename to crates/engine/src/canvas/vectoredit/regions_tests.rs diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs similarity index 100% rename from crates/engine/src/vectoredit/tests.rs rename to crates/engine/src/canvas/vectoredit/tests.rs diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs similarity index 100% rename from crates/engine/src/vectoredit/transform.rs rename to crates/engine/src/canvas/vectoredit/transform.rs diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/canvas/vectoredit/transform_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/transform_tests.rs rename to crates/engine/src/canvas/vectoredit/transform_tests.rs diff --git a/crates/engine/src/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs similarity index 100% rename from crates/engine/src/vectoredit/width.rs rename to crates/engine/src/canvas/vectoredit/width.rs diff --git a/crates/engine/src/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_sides_tests.rs rename to crates/engine/src/canvas/vectoredit/width_sides_tests.rs diff --git a/crates/engine/src/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_tests.rs rename to crates/engine/src/canvas/vectoredit/width_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 428738d11..5abe79b8d 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,17 @@ use crate::components::*; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +mod instance; +mod props; +mod variants; + +use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, reset_override, reset_slot, restore_main, slot_insert, swap}; +pub(crate) use instance::{swap_at, swap_instance}; +use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props}; +pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props}; +use variants::{add_variant, combine}; +pub use variants::{can_add_variant, can_combine_variants}; + /// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and /// sets (those `ids` are or are in), put on or taken off. fn hide_when_publishing(s: &mut Session, p: &Value) -> Result { @@ -343,1185 +354,3 @@ fn set_name_from(variant: &str) -> Option { let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) } - -/// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; - if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); - } - let bounds = doc.bounds_of(ids).unwrap_or_default(); - let pw = doc.world_transform(parent); - let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); - let set = doc.alloc_id(); - let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); - n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); - n.fills.clear(); - doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; - for i in ids { - doc.reparent(*i, set, None)?; - } - Ok(set) -} - -const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; - -/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they -/// can: anything but main components, components that are variants already (combining them would -/// put a set in a set), library copies, components inside an instance, and a component inside -/// another one of `ids`. -fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { - if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - Some("select components only") - } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { - Some("components that are variants already can't be combined again") - } else if ids.iter().any(|i| doc.is_remote(*i)) { - Some(crate::library::READ_ONLY) - } else if ids.iter().any(|i| inside_instance(doc, *i)) { - Some(INSIDE_INSTANCE) - } else if roots(doc, ids).len() != ids.len() { - Some("a component can't be combined with one inside it") - } else { - None - } -} - -/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more -/// main components that [`combine_refusal`] lets through. -pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { - ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() -} - -/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main -/// component not in a set yet (it becomes one); never a library copy or inside an instance. -pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { - let Some(n) = doc.node(id) else { return false }; - let local = !doc.is_remote(id) && !inside_instance(doc, id); - match n.kind { - NodeKind::ComponentSet { .. } => { - local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) - } - NodeKind::Component { .. } => local, - _ => false, - } -} - -/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", -/// "Property 1=VariantN" unless already in prop=value form. Returns the set. -fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let set = wrap_in_set(cmd, doc, ids)?; - for (k, i) in ids.iter().enumerate() { - let node = doc.get_mut(*i)?; - if !node.name.contains('=') { - node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); - } - } - Ok(set) -} - -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let mut seen = BTreeSet::new(); - let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); - let doc = s.doc_mut()?; - if let Some(why) = combine_refusal(doc, &ids) { - return Err(EngineError::Disabled(cmd.into(), why.into())); - } - let ids = z_sorted(doc, &ids); - let set = make_variant_set(cmd, doc, &ids)?; - s.set_selection(vec![set]); - Ok(json!({"id": set})) -} - -/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). -fn fit_set(doc: &mut Document, set: NodeId) { - let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; - let (mut w, mut h) = (0.0f64, 0.0f64); - for c in kids { - if let Some(n) = doc.node(c) { - let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); - w = w.max(b.x1 + 20.0); - h = h.max(b.y1 + 20.0); - } - } - if let Some(n) = doc.node_mut(set) { - n.width = n.width.max(w); - n.height = n.height.max(h); - } -} - -fn add_variant(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; - let doc = s.doc_mut()?; - let (set, base) = match doc.get(t)?.kind { - NodeKind::ComponentSet { .. } => { - let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; - (t, last) - } - NodeKind::Component { .. } => match variant_set(doc, t) { - Some(set) => (set, t), - // A main component on its own becomes a set of itself, then gains the new variant. - None => (make_variant_set(cmd, doc, &[t])?, t), - }, - _ => return Err(bad(cmd, "select a component set or component")), - }; - let options = variant_options(doc, set); - let mut pairs = variant_props(&doc.get(base)?.name); - let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); - let existing: BTreeSet> = - doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); - match (string(p, "property"), string(p, "value")) { - (Some(prop), Some(value)) => { - let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); - if !options.iter().any(|(k, _)| *k == prop) { - return Err(bad(cmd, format!("unknown variant property `{prop}`"))); - } - if value.is_empty() || value.contains([',', '=']) { - return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); - } - pairs.insert(prop, value); - if existing.contains(&pairs) { - return Err(bad(cmd, "that variant already exists")); - } - } - (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), - _ => { - let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); - let mut k = existing.len() + 1; - loop { - pairs.insert(prop.clone(), format!("Variant{k}")); - if !existing.contains(&pairs) || k > 10_000 { - break; - } - k += 1; - } - } - } - let name = variant_name(&pairs, &order); - let bottom = doc - .get(set)? - .children - .iter() - .filter_map(|c| doc.node(*c)) - .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) - .fold(0.0f64, f64::max); - let dup = doc.duplicate_into(base, set, None)?; - let n = doc.get_mut(dup)?; - n.name = name; - let c = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); - fit_set(doc, set); - s.set_selection(vec![dup]); - Ok(json!({"id": dup})) -} - -fn detach(s: &mut Session, p: &Value) -> Result { - let cmd = "component.detach"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for i in ids { - if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { - continue; - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); - } - // Own layers lose their link (slot content stays as it is); nested instances stay - // instances of their own mains. - let nested = unlink_layers(doc, i, Some(i)); - keep_main_modes(doc, i)?; - let n = doc.get_mut(i)?; - n.kind = NodeKind::Frame; - n.source = None; - n.overrides.clear(); - n.prop_refs.remove("mainComponent"); - // No properties left to drive. - n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - for nn in nested { - rebase_instance(doc, nn); - } - out.push(i); - } - if out.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -/// Make the variable modes instance `i` takes from its main its own (it's being detached). -fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { - let modes = match doc.get(i)?.kind { - NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), - _ => return Ok(()), - }; - let own = &mut doc.get_mut(i)?.var_modes; - for (c, m) in modes { - own.entry(c).or_insert(m); - } - Ok(()) -} - -fn reset_instance(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetInstance"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut done = Vec::new(); - for i in ids { - let Some(inst) = owning_instance(doc, i) else { continue }; - let top = !inside_instance(doc, inst); - for d in doc.subtree(inst) { - let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; - if let Some(n) = doc.node(d) - && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) - && let Some(n) = doc.node_mut(d) - { - n.overrides.retain(|g| keep.contains(&g.as_str())); - } - } - // Property values go back to the defaults. - if top - && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind - { - *props = defaults; - } - // ...and stop following variables. - if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { - doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - } - done.push(inst); - } - if done.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - Ok(json!({"ids": done})) -} - -fn reset_override(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetOverride"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; - let group = string(p, "group"); - if let Some(g) = &group - && !OVERRIDE_GROUPS.contains(&g.as_str()) - { - return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); - } - let doc = s.doc_mut()?; - if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { - return Err(bad(cmd, "not a layer of an instance")); - } - let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); - let had = doc.get(i)?.overrides.clone(); - let n = doc.get_mut(i)?; - match &group { - Some(g) => { - n.overrides.remove(g); - } - None => n.overrides.clear(), - } - let mut changed = had != doc.get(i)?.overrides; - // The instance's own property values reset with `props`. - if top_root - && group.as_deref().is_none_or(|g| g == "props") - && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind - { - *props = defaults; - changed = true; - } - Ok(json!({"changed": changed})) -} - -/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. -fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { - let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); - let base = if base.is_empty() { "Slot".to_string() } else { base }; - let taken = |n: &str| { - owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) - || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) - }; - (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) -} - -/// The `preferred` components of a slot property: component or component set ids. -fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { - let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; - let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; - let mut out = Vec::new(); - for x in list.iter().take(500) { - let c = id_of(x) - .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) - .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; - if !out.contains(&c) { - out.push(c); - } - } - Ok(out) -} - -fn make_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.makeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; - let doc = s.doc()?; - let n = doc.get(id)?; - let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); - if !matches!(n.kind, NodeKind::Frame) || !inside_main { - return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); - } - if is_slot(doc, id) { - return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); - } - let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; - let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); - let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) -} - -fn remove_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.removeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; - let doc = s.doc()?; - if owning_instance(doc, id).is_some() { - return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); - } - let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; - delete_prop(s, &json!({"id": id, "name": name}))?; - Ok(json!({"id": id, "name": name})) -} - -/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. -fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { - let slot = match name { - Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, - None => id, - }; - if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { - return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); - } - Ok(slot) -} - -fn slot_insert(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.slotInsert"; - let c = req_id(cmd, p, "component")?; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let index = match get(p, "index").filter(|v| !v.is_null()) { - None => None, - Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), - }; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - if boolean(p, "replace").unwrap_or(false) { - for k in doc.get(slot)?.children.clone() { - doc.remove(k)?; - } - } - let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; - s.set_selection(vec![i]); - Ok(json!({"id": i, "slot": slot})) -} - -fn reset_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.resetSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; - let changed = slot_edited(doc, slot); - if changed { - doc.get_mut(slot)?.overrides.remove(SLOT); - } - Ok(json!({"id": slot, "changed": changed})) -} - -fn push_changes(s: &mut Session, p: &Value) -> Result { - let cmd = "component.pushChanges"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - // Changes go to the outer instance's main (never into a nested instance's own main). - let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; - if !doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); - } - // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, - // property values and slot content stay with the instance). - let mut pushed = 0; - for d in doc.subtree(inst) { - let Some(n) = doc.node(d).cloned() else { continue }; - let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; - if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { - continue; - } - let Some(mut target) = doc.node(src).cloned() else { continue }; - let groups: BTreeSet = n - .overrides - .iter() - .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) - .cloned() - .collect(); - for g in &groups { - copy_group(&n, &mut target, g); - } - if groups.is_empty() { - continue; - } - pushed += 1; - if let Some(slot) = doc.node_mut(src) { - *slot = target; - } - if let Some(m) = doc.node_mut(d) { - m.overrides.retain(|g| !groups.contains(g)); - } - } - Ok(json!({"layers": pushed})) -} - -fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { - let mut defs = BTreeMap::new(); - for (k, v) in props { - let def = match v { - Value::Bool(b) => PropDef::Boolean { default: *b }, - Value::String(t) => PropDef::Text { default: t.clone() }, - other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } - defs -} - -fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { - let mut map: BTreeMap = BTreeMap::new(); - let mut used: BTreeSet = BTreeSet::new(); - map.insert(i, component); - used.insert(component); - for d in sub.iter().filter(|d| **d != i) { - let src = doc.node(*d).and_then(|n| n.source); - let nid = match src { - Some(s) if !doc.contains(s) && used.insert(s) => s, - _ => doc.alloc_id(), - }; - map.insert(*d, nid); - } - map -} - -fn restore_main(s: &mut Session, p: &Value) -> Result { - let cmd = "component.restoreMain"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, "select an instance")); - }; - if doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); - } - let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; - let index = doc.index_in_parent(i); - // The main comes back with its old id (other instances point to it) and its layers with the - // ids their copies mirror, so every instance re-links by source. - let sub = doc.subtree(i); - let map = restore_id_map(doc, i, component, &sub); - let mut nodes = Vec::new(); - let mut nested = Vec::new(); - for d in &sub { - let Some(mut n) = doc.node(*d).cloned() else { continue }; - if *d == i { - let defs = restore_prop_defs(doc, &props); - n.kind = NodeKind::Component { description: String::new(), props: defs }; - n.source = None; - n.overrides.clear(); - n.prop_refs.clear(); - } else { - let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); - if parent_inst != Some(i) { - nodes.push(n); - continue; - } - if matches!(n.kind, NodeKind::Instance { .. }) { - nested.push(*d); - } else { - n.source = None; - n.overrides.clear(); - } - } - nodes.push(n); - } - doc.remove(i)?; - doc.paste_nodes(&nodes, &map, i, parent, index)?; - for nn in nested { - if let Some(m) = map.get(&nn).copied() { - rebase_instance(doc, m); - } - } - s.set_selection(vec![component]); - Ok(json!({"id": component})) -} - -fn swap(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swap"; - let c = req_id(cmd, p, "component")?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - for i in ids { - swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; - } - ok() -} - -/// Make instance `i` show main component `c`, keeping the values of properties with the same names -/// (the layers' overrides follow by name when the instance is rebuilt). -pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { - if doc.is_ancestor(c, i) { - return Err("an instance can't show the component it is inside".into()); - } - let defaults = doc.node(c).map(default_props).unwrap_or_default(); - match doc.get_mut(i).map(|n| &mut n.kind) { - Ok(NodeKind::Instance { component, props }) => { - *component = c; - let old = std::mem::take(props); - *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); - Ok(()) - } - _ => Err(format!("{i} is not an instance")), - } -} - -/// Swap the instance under a page point to a component (`instance.swapAt`). -pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swapAt"; - let c = req_id(cmd, p, "component")?; - let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let page = s.page()?; - let doc = s.doc()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - let family = crate::drop::swap_family(doc, c); - let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; - keep_text_overrides(s.doc_mut()?, target); - swap(s, &json!({"ids": [target], "component": c}))?; - s.set_selection(vec![target]); - Ok(json!({"id": target, "component": c})) -} - -/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a -/// boolean property takes a boolean variable, a text or variant property a string one). -pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { - return Err(format!("{id} is not an instance (component properties are bound on instances)")); - }; - if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { - return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; - } - match (prop_defs(doc, *component).get(name), kind) { - (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), - (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), - (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), - (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), - (None, _) => Err(format!("the instance has no property named `{name}`")), - } -} - -/// Write bound variable values into instance `id`'s boolean and text property values, and switch -/// it to the variant its bound variant properties name (only what changed; the instance then -/// follows like after `instance.setProperties`). A value that names no variant changes nothing. -pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; - let component = *component; - if let Some(set) = variant_set(doc, component) { - let options = variant_options(doc, set); - let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut wanted = current.clone(); - for (name, val) in sets { - if let VariableValue::String(t) = val - && options.iter().any(|(o, vals)| o == name && vals.contains(t)) - { - wanted.insert(name.clone(), t.clone()); - } - } - if wanted != current - && let Some(target) = find_variant(doc, set, &wanted) - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { component: c, .. } = &mut n.kind - { - *c = target; - } - } - let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; - let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; - let mut next = props.clone(); - for (name, val) in sets { - let v = match (defs.get(name), val) { - (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), - (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), - _ => continue, - }; - next.insert(name.clone(), v); - } - if next != *props - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { props, .. } = &mut n.kind - { - *props = next; - } -} - -/// Coerce a property value to the definition's type. -fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { - match def { - PropDef::Boolean { .. } => match v { - Value::Bool(b) => Ok(json!(b)), - Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), - _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), - }, - PropDef::Text { .. } => match v { - Value::String(t) => Ok(json!(t)), - Value::Number(n) => Ok(json!(n.to_string())), - _ => Err(bad(cmd, format!("`{name}` is a text property"))), - }, - PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { - Some(c) => Ok(json!(c.0)), - None => Err(bad(cmd, format!("`{name}` takes a component id"))), - }, - PropDef::Variant { options, .. } => { - let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } - } - PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), - } -} - -fn set_props(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.setProperties"; - let ids = req_targets(cmd, s, p)?; - let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; - let doc = s.doc_mut()?; - for i in ids { - let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, format!("{i} is not an instance"))); - }; - // Variant properties: switch to the matching sibling variant. - let set = variant_set(doc, component); - let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); - let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut target = component; - let mut rest = Vec::new(); - for (k, v) in &props { - if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { - let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if !vals.contains(&val) { - return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); - } - wanted.insert(k.clone(), val); - } else { - rest.push((k.clone(), v.clone())); - } - } - if let Some(set) = set - && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() - { - target = find_variant(doc, set, &wanted).unwrap_or(component); - } - let defs = match doc.node(target).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - }; - let mut values = Vec::new(); - for (k, v) in rest { - let def = defs.get(&k).ok_or_else(|| { - let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); - bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) - })?; - values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); - } - if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { - *c = target; - for (k, v) in values { - ip.insert(k, v); - } - for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { - ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); - } - } - } - ok() -} - -/// Where component properties live: a set (its variants share them) or a lone component. -enum Owner { - Set(NodeId), - Comp(NodeId), -} - -impl Owner { - fn resolve(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - match n.kind { - NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), - _ => { - let c = owning_component(doc, id)?; - Some(match variant_set(doc, c) { - Some(s) => Owner::Set(s), - None => Owner::Comp(c), - }) - } - } - } - - fn components(&self, doc: &Document) -> Vec { - match self { - Owner::Comp(c) => vec![*c], - Owner::Set(s) => doc - .node(*s) - .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) - .unwrap_or_default(), - } - } - - fn id(&self) -> NodeId { - match self { - Owner::Set(s) | Owner::Comp(s) => *s, - } - } -} - -fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { - match doc.node(c).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - } -} - -fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { - if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { - f(props); - } - Ok(()) -} - -fn valid_prop_name(cmd: &str, name: &str) -> Result { - let name = name.trim(); - if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { - return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); - } - Ok(name.to_string()) -} - -fn add_prop(s: &mut Session, p: &Value) -> Result { - add_prop_for("component.addProperty", s, p) -} - -/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its -/// slot property here). -fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; - let t = req_string(cmd, p, "type")?; - let doc = s.doc_mut()?; - if inside_instance(doc, target) { - return Err(bad(cmd, "select a component, not a layer of an instance")); - } - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; - // A layer selected inside the component gets bound to the new property. - let layer = id(p, "layer") - .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); - let comps = owner.components(doc); - let variants = match &owner { - Owner::Set(s) => variant_options(doc, *s), - Owner::Comp(_) => Vec::new(), - }; - if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { - return Err(bad(cmd, format!("a property named `{name}` already exists"))); - } - let layer_node = layer.and_then(|l| doc.node(l)).cloned(); - let def = match t.to_ascii_lowercase().as_str() { - // Without a default, a bound layer keeps showing as it does: its visibility is the default. - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, - "text" => PropDef::Text { - default: string(p, "default") - .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) - .unwrap_or_else(|| name.clone()), - }, - "instanceswap" | "instance_swap" | "instance" => { - let d = id(p, "default") - .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { - Some(NodeKind::Instance { component, .. }) => Some(*component), - _ => None, - }) - .and_then(|c| main_of(doc, c)) - .ok_or_else(|| bad(cmd, "default: a component id"))?; - PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } - } - "variant" => { - let dv = string(p, "default") - .map(|d| d.trim().to_string()) - .filter(|d| !d.is_empty() && !d.contains([',', '='])) - .unwrap_or_else(|| "Default".into()); - let set = match owner { - Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, - }; - for c in Owner::Set(set).components(doc) { - let cn = doc.get_mut(c)?; - cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; - } - s.set_selection(vec![set]); - return Ok(json!({"id": set})); - } - "slot" => { - if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { - return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); - } - PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } - } - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), - }; - for c in &comps { - set_prop_defs(doc, *c, |m| { - m.insert(name.clone(), def.clone()); - })?; - // Existing instances get the default. - if !takes_value(&def) { - continue; - } - for inst in instances_of(doc, *c) { - if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { - props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); - } - } - } - if let Some(l) = layer { - bind(cmd, doc, l, Some(&name), None)?; - } - Ok(json!({"id": owner.id(), "name": name})) -} - -/// Bind (or with `property: None` unbind) a layer's field to a property of its component. -fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { - let n = doc.get(layer)?.clone(); - if inside_instance(doc, layer) { - return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); - } - let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; - let Some(property) = property.filter(|p| !p.is_empty()) else { - let n = doc.get_mut(layer)?; - match field { - Some(f) => { - n.prop_refs.remove(f); - } - None => n.prop_refs.clear(), - } - return Ok(()); - }; - let defs = prop_defs(doc, comp); - let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; - let expected = match def { - PropDef::Boolean { .. } => "visible", - PropDef::Text { .. } => "characters", - PropDef::InstanceSwap { .. } => "mainComponent", - PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), - PropDef::Slot { .. } => SLOT, - }; - let field = field.unwrap_or(expected); - if field != expected { - return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); - } - match field { - "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), - "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), - "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), - SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), - SLOT if doc - .subtree(comp) - .into_iter() - .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => - { - return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); - } - _ => {} - } - let n = doc.get_mut(layer)?; - n.prop_refs.insert(field.to_string(), property.to_string()); - // On the main, a boolean property's value is the visibility of the layers it controls. - if let PropDef::Boolean { default } = def { - n.visible = *default; - } - Ok(()) -} - -fn bind_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.bindProperty"; - let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; - let property = string(p, "property"); - let field = string(p, "field"); - if let Some(f) = &field - && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) - { - return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); - } - let doc = s.doc_mut()?; - bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; - ok() -} - -/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. -fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { - for d in doc.subtree(comp) { - let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); - if refs.values().any(|v| v == old) - && let Some(n) = doc.node_mut(d) - { - match new { - Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), - None => n.prop_refs.retain(|_, v| v != old), - } - } - } - // Instances' values and the variables bound to them follow (a deleted property drops both). - let old_key = format!("{PROP_BINDING}{old}"); - for inst in instances_of(doc, comp) { - let n = doc.get_mut(inst)?; - if let NodeKind::Instance { props, .. } = &mut n.kind - && let Some(v) = props.remove(old) - && let Some(nw) = new - { - props.insert(nw.to_string(), v); - } - if let Some(var) = n.bound.remove(&old_key) - && let Some(nw) = new - { - n.bound.insert(format!("{PROP_BINDING}{nw}"), var); - } - } - Ok(()) -} - -fn edit_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.editProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Some(nn) = &new_name { - let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) - || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); - if taken { - return Err(bad(cmd, format!("a property named `{nn}` already exists"))); - } - } - // Variant property of a set. - if let Owner::Set(set) = owner - && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - let rename_value = match (string(p, "value"), string(p, "newValue")) { - (Some(v), Some(nv)) => { - let nv = nv.trim().to_string(); - if !values.contains(&v) { - return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); - } - if nv.is_empty() || nv.contains([',', '=']) { - return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); - } - Some((v, nv)) - } - _ => None, - }; - let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); - // Reordered: every variant's name lists the properties in the new order. - if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { - let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; - let key = new_name.clone().unwrap_or(name.clone()); - order.retain(|k| *k != key); - order.insert(to.min(order.len()), key); - } - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - if let Some((v, nv)) = &rename_value - && pairs.get(&name) == Some(v) - { - pairs.insert(name.clone(), nv.clone()); - } - if let Some(nn) = &new_name - && let Some(v) = pairs.remove(&name) - { - pairs.insert(nn.clone(), v); - } - doc.get_mut(*c)?.name = variant_name(&pairs, &order); - } - // The default variant is the top-left one: the variant with that value (and the default's - // other values, when there is one) trades places with it. - if let Some(dv) = string(p, "default") { - let key = new_name.clone().unwrap_or(name.clone()); - let current = default_variant(doc, set); - let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - wanted.insert(key.clone(), dv.clone()); - let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); - let pick = - comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); - let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; - if f != d { - let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); - let (fp, dp) = (place(doc, f), place(doc, d)); - for (c, (x, y)) in [(f, dp), (d, fp)] { - let n = doc.get_mut(c)?; - let [a, b, cc, dd, ..] = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); - } - fit_set(doc, set); - } - } - return ok(); - } - let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; - let preferred = if get(p, "preferred").is_some() { - if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { - return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); - } - Some(preferred_arg(cmd, doc, p)?) - } else { - None - }; - let mut new_def = match get(p, "default") { - Some(v) => match coerce(cmd, doc, &name, &def, v)? { - Value::Bool(b) => PropDef::Boolean { default: b }, - Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, - other => match (&def, id_of(&other)) { - (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, - _ => def.clone(), - }, - }, - None => def.clone(), - }; - if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { - *preferred = list; - } - for c in &comps { - let had = prop_defs(doc, *c).contains_key(&name); - if !had { - continue; - } - let key = new_name.clone().unwrap_or(name.clone()); - let nd = new_def.clone(); - let old = name.clone(); - set_prop_defs(doc, *c, move |m| { - m.remove(&old); - m.insert(key, nd); - })?; - if let Some(nn) = &new_name { - rename_prop_refs(doc, *c, &name, Some(nn))?; - } - } - // The main's bound layers show the new default. - if let PropDef::Boolean { default } = new_def { - set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); - } - ok() -} - -fn delete_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.deleteProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Owner::Set(set) = owner - && variant_options(doc, set).iter().any(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - pairs.remove(&name); - let n = doc.get_mut(*c)?; - n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; - } - return ok(); - } - let mut found = false; - for c in &comps { - if prop_defs(doc, *c).contains_key(&name) { - found = true; - set_prop_defs(doc, *c, |m| { - m.remove(&name); - })?; - rename_prop_refs(doc, *c, &name, None)?; - } - } - if !found { - return Err(bad(cmd, format!("no property named `{name}`"))); - } - ok() -} - -fn def_json(name: &str, d: &PropDef) -> Value { - match d { - PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), - PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), - PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), - PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), - PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), - } -} - -/// Properties of a component, set or instance as JSON (`component.properties`). -pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let (owner_comp, values) = match &n.kind { - NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), - _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), - }; - let Some(comp) = owner_comp else { return json!({"properties": []}) }; - let mut list: Vec = Vec::new(); - if let Some(set) = variant_set(doc, comp) { - let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); - let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - for (k, vals) in variant_options(doc, set) { - let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); - if values.is_some() { - v["value"] = json!(current.get(&k)); - } - list.push(v); - } - } - // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. - let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); - for (k, d) in prop_defs(doc, comp) { - let mut v = def_json(&k, &d); - if !takes_value(&d) { - let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); - if values.is_some() { - v["value"] = layers.first().map_or(Value::Null, |l| { - let count = doc.node(*l).map_or(0, |n| n.children.len()); - json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) - }); - } else { - v["layers"] = json!(layers); - } - } else if let Some(vals) = &values { - v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); - } - list.push(v); - } - let set = variant_set(doc, comp); - let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); - json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) -} diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs new file mode 100644 index 000000000..ffc5ec1c8 --- /dev/null +++ b/crates/engine/src/cmd/component/instance.rs @@ -0,0 +1,404 @@ +use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; +use super::*; + +pub(super) fn detach(s: &mut Session, p: &Value) -> Result { + let cmd = "component.detach"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut out = Vec::new(); + for i in ids { + if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { + continue; + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); + } + // Own layers lose their link (slot content stays as it is); nested instances stay + // instances of their own mains. + let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; + let n = doc.get_mut(i)?; + n.kind = NodeKind::Frame; + n.source = None; + n.overrides.clear(); + n.prop_refs.remove("mainComponent"); + // No properties left to drive. + n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + for nn in nested { + rebase_instance(doc, nn); + } + out.push(i); + } + if out.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + +pub(super) fn reset_instance(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetInstance"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut done = Vec::new(); + for i in ids { + let Some(inst) = owning_instance(doc, i) else { continue }; + let top = !inside_instance(doc, inst); + for d in doc.subtree(inst) { + let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; + if let Some(n) = doc.node(d) + && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) + && let Some(n) = doc.node_mut(d) + { + n.overrides.retain(|g| keep.contains(&g.as_str())); + } + } + // Property values go back to the defaults. + if top + && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind + { + *props = defaults; + } + // ...and stop following variables. + if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { + doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + } + done.push(inst); + } + if done.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + Ok(json!({"ids": done})) +} + +pub(super) fn reset_override(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetOverride"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; + let group = string(p, "group"); + if let Some(g) = &group + && !OVERRIDE_GROUPS.contains(&g.as_str()) + { + return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); + } + let doc = s.doc_mut()?; + if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { + return Err(bad(cmd, "not a layer of an instance")); + } + let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); + let had = doc.get(i)?.overrides.clone(); + let n = doc.get_mut(i)?; + match &group { + Some(g) => { + n.overrides.remove(g); + } + None => n.overrides.clear(), + } + let mut changed = had != doc.get(i)?.overrides; + // The instance's own property values reset with `props`. + if top_root + && group.as_deref().is_none_or(|g| g == "props") + && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind + { + *props = defaults; + changed = true; + } + Ok(json!({"changed": changed})) +} + +/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. +fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { + let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); + let base = if base.is_empty() { "Slot".to_string() } else { base }; + let taken = |n: &str| { + owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) + || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) + }; + (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) +} + +/// The `preferred` components of a slot property: component or component set ids. +pub(super) fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { + let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; + let mut out = Vec::new(); + for x in list.iter().take(500) { + let c = id_of(x) + .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) + .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; + if !out.contains(&c) { + out.push(c); + } + } + Ok(out) +} + +pub(super) fn make_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.makeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; + let doc = s.doc()?; + let n = doc.get(id)?; + let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); + if !matches!(n.kind, NodeKind::Frame) || !inside_main { + return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); + } + if is_slot(doc, id) { + return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); + } + let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; + let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); + let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) +} + +pub(super) fn remove_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.removeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; + let doc = s.doc()?; + if owning_instance(doc, id).is_some() { + return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); + } + let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; + delete_prop(s, &json!({"id": id, "name": name}))?; + Ok(json!({"id": id, "name": name})) +} + +/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. +fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { + let slot = match name { + Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, + None => id, + }; + if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { + return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); + } + Ok(slot) +} + +pub(super) fn slot_insert(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.slotInsert"; + let c = req_id(cmd, p, "component")?; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let index = match get(p, "index").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), + }; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + if boolean(p, "replace").unwrap_or(false) { + for k in doc.get(slot)?.children.clone() { + doc.remove(k)?; + } + } + let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; + s.set_selection(vec![i]); + Ok(json!({"id": i, "slot": slot})) +} + +pub(super) fn reset_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.resetSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; + let changed = slot_edited(doc, slot); + if changed { + doc.get_mut(slot)?.overrides.remove(SLOT); + } + Ok(json!({"id": slot, "changed": changed})) +} + +pub(super) fn push_changes(s: &mut Session, p: &Value) -> Result { + let cmd = "component.pushChanges"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + // Changes go to the outer instance's main (never into a nested instance's own main). + let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). + let mut pushed = 0; + for d in doc.subtree(inst) { + let Some(n) = doc.node(d).cloned() else { continue }; + let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; + if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { + continue; + } + let Some(mut target) = doc.node(src).cloned() else { continue }; + let groups: BTreeSet = n + .overrides + .iter() + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) + .cloned() + .collect(); + for g in &groups { + copy_group(&n, &mut target, g); + } + if groups.is_empty() { + continue; + } + pushed += 1; + if let Some(slot) = doc.node_mut(src) { + *slot = target; + } + if let Some(m) = doc.node_mut(d) { + m.overrides.retain(|g| !groups.contains(g)); + } + } + Ok(json!({"layers": pushed})) +} + +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + let mut defs = BTreeMap::new(); + for (k, v) in props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + map +} + +pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result { + let cmd = "component.restoreMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "select an instance")); + }; + if doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); + } + let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; + let index = doc.index_in_parent(i); + // The main comes back with its old id (other instances point to it) and its layers with the + // ids their copies mirror, so every instance re-links by source. + let sub = doc.subtree(i); + let map = restore_id_map(doc, i, component, &sub); + let mut nodes = Vec::new(); + let mut nested = Vec::new(); + for d in &sub { + let Some(mut n) = doc.node(*d).cloned() else { continue }; + if *d == i { + let defs = restore_prop_defs(doc, &props); + n.kind = NodeKind::Component { description: String::new(), props: defs }; + n.source = None; + n.overrides.clear(); + n.prop_refs.clear(); + } else { + let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); + if parent_inst != Some(i) { + nodes.push(n); + continue; + } + if matches!(n.kind, NodeKind::Instance { .. }) { + nested.push(*d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nodes.push(n); + } + doc.remove(i)?; + doc.paste_nodes(&nodes, &map, i, parent, index)?; + for nn in nested { + if let Some(m) = map.get(&nn).copied() { + rebase_instance(doc, m); + } + } + s.set_selection(vec![component]); + Ok(json!({"id": component})) +} + +pub(super) fn swap(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swap"; + let c = req_id(cmd, p, "component")?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + for i in ids { + swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; + } + ok() +} + +/// Make instance `i` show main component `c`, keeping the values of properties with the same names +/// (the layers' overrides follow by name when the instance is rebuilt). +pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { + if doc.is_ancestor(c, i) { + return Err("an instance can't show the component it is inside".into()); + } + let defaults = doc.node(c).map(default_props).unwrap_or_default(); + match doc.get_mut(i).map(|n| &mut n.kind) { + Ok(NodeKind::Instance { component, props }) => { + *component = c; + let old = std::mem::take(props); + *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); + Ok(()) + } + _ => Err(format!("{i} is not an instance")), + } +} + +/// Swap the instance under a page point to a component (`instance.swapAt`). +pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swapAt"; + let c = req_id(cmd, p, "component")?; + let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let page = s.page()?; + let doc = s.doc()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let family = crate::drop::swap_family(doc, c); + let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + keep_text_overrides(s.doc_mut()?, target); + swap(s, &json!({"ids": [target], "component": c}))?; + s.set_selection(vec![target]); + Ok(json!({"id": target, "component": c})) +} diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs new file mode 100644 index 000000000..3f2d70020 --- /dev/null +++ b/crates/engine/src/cmd/component/props.rs @@ -0,0 +1,609 @@ +use super::instance::preferred_arg; +use super::variants::{fit_set, wrap_in_set}; +use super::*; + +/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a +/// boolean property takes a boolean variable, a text or variant property a string one). +pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { + return Err(format!("{id} is not an instance (component properties are bound on instances)")); + }; + if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { + return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; + } + match (prop_defs(doc, *component).get(name), kind) { + (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), + (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), + (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), + (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), + (None, _) => Err(format!("the instance has no property named `{name}`")), + } +} + +/// Write bound variable values into instance `id`'s boolean and text property values, and switch +/// it to the variant its bound variant properties name (only what changed; the instance then +/// follows like after `instance.setProperties`). A value that names no variant changes nothing. +pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; + let component = *component; + if let Some(set) = variant_set(doc, component) { + let options = variant_options(doc, set); + let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut wanted = current.clone(); + for (name, val) in sets { + if let VariableValue::String(t) = val + && options.iter().any(|(o, vals)| o == name && vals.contains(t)) + { + wanted.insert(name.clone(), t.clone()); + } + } + if wanted != current + && let Some(target) = find_variant(doc, set, &wanted) + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { component: c, .. } = &mut n.kind + { + *c = target; + } + } + let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; + let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; + let mut next = props.clone(); + for (name, val) in sets { + let v = match (defs.get(name), val) { + (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), + (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), + _ => continue, + }; + next.insert(name.clone(), v); + } + if next != *props + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { props, .. } = &mut n.kind + { + *props = next; + } +} + +/// Coerce a property value to the definition's type. +fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { + match def { + PropDef::Boolean { .. } => match v { + Value::Bool(b) => Ok(json!(b)), + Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), + _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), + }, + PropDef::Text { .. } => match v { + Value::String(t) => Ok(json!(t)), + Value::Number(n) => Ok(json!(n.to_string())), + _ => Err(bad(cmd, format!("`{name}` is a text property"))), + }, + PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { + Some(c) => Ok(json!(c.0)), + None => Err(bad(cmd, format!("`{name}` takes a component id"))), + }, + PropDef::Variant { options, .. } => { + let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } + } + PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), + } +} + +pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.setProperties"; + let ids = req_targets(cmd, s, p)?; + let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; + let doc = s.doc_mut()?; + for i in ids { + let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, format!("{i} is not an instance"))); + }; + // Variant properties: switch to the matching sibling variant. + let set = variant_set(doc, component); + let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); + let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut target = component; + let mut rest = Vec::new(); + for (k, v) in &props { + if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { + let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if !vals.contains(&val) { + return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); + } + wanted.insert(k.clone(), val); + } else { + rest.push((k.clone(), v.clone())); + } + } + if let Some(set) = set + && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() + { + target = find_variant(doc, set, &wanted).unwrap_or(component); + } + let defs = match doc.node(target).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + }; + let mut values = Vec::new(); + for (k, v) in rest { + let def = defs.get(&k).ok_or_else(|| { + let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); + bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) + })?; + values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); + } + if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { + *c = target; + for (k, v) in values { + ip.insert(k, v); + } + for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { + ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); + } + } + } + ok() +} + +/// Where component properties live: a set (its variants share them) or a lone component. +pub(super) enum Owner { + Set(NodeId), + Comp(NodeId), +} + +impl Owner { + pub(super) fn resolve(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + match n.kind { + NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), + _ => { + let c = owning_component(doc, id)?; + Some(match variant_set(doc, c) { + Some(s) => Owner::Set(s), + None => Owner::Comp(c), + }) + } + } + } + + pub(super) fn components(&self, doc: &Document) -> Vec { + match self { + Owner::Comp(c) => vec![*c], + Owner::Set(s) => doc + .node(*s) + .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) + .unwrap_or_default(), + } + } + + fn id(&self) -> NodeId { + match self { + Owner::Set(s) | Owner::Comp(s) => *s, + } + } +} + +pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { + match doc.node(c).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + } +} + +fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { + if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { + f(props); + } + Ok(()) +} + +fn valid_prop_name(cmd: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { + return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); + } + Ok(name.to_string()) +} + +pub(super) fn add_prop(s: &mut Session, p: &Value) -> Result { + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; + let t = req_string(cmd, p, "type")?; + let doc = s.doc_mut()?; + if inside_instance(doc, target) { + return Err(bad(cmd, "select a component, not a layer of an instance")); + } + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; + // A layer selected inside the component gets bound to the new property. + let layer = id(p, "layer") + .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); + let comps = owner.components(doc); + let variants = match &owner { + Owner::Set(s) => variant_options(doc, *s), + Owner::Comp(_) => Vec::new(), + }; + if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { + return Err(bad(cmd, format!("a property named `{name}` already exists"))); + } + let layer_node = layer.and_then(|l| doc.node(l)).cloned(); + let def = match t.to_ascii_lowercase().as_str() { + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, + "text" => PropDef::Text { + default: string(p, "default") + .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) + .unwrap_or_else(|| name.clone()), + }, + "instanceswap" | "instance_swap" | "instance" => { + let d = id(p, "default") + .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + }) + .and_then(|c| main_of(doc, c)) + .ok_or_else(|| bad(cmd, "default: a component id"))?; + PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } + } + "variant" => { + let dv = string(p, "default") + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty() && !d.contains([',', '='])) + .unwrap_or_else(|| "Default".into()); + let set = match owner { + Owner::Set(s) => s, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, + }; + for c in Owner::Set(set).components(doc) { + let cn = doc.get_mut(c)?; + cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; + } + s.set_selection(vec![set]); + return Ok(json!({"id": set})); + } + "slot" => { + if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { + return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); + } + PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } + } + _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), + }; + for c in &comps { + set_prop_defs(doc, *c, |m| { + m.insert(name.clone(), def.clone()); + })?; + // Existing instances get the default. + if !takes_value(&def) { + continue; + } + for inst in instances_of(doc, *c) { + if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { + props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); + } + } + } + if let Some(l) = layer { + bind(cmd, doc, l, Some(&name), None)?; + } + Ok(json!({"id": owner.id(), "name": name})) +} + +/// Bind (or with `property: None` unbind) a layer's field to a property of its component. +fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { + let n = doc.get(layer)?.clone(); + if inside_instance(doc, layer) { + return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); + } + let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; + let Some(property) = property.filter(|p| !p.is_empty()) else { + let n = doc.get_mut(layer)?; + match field { + Some(f) => { + n.prop_refs.remove(f); + } + None => n.prop_refs.clear(), + } + return Ok(()); + }; + let defs = prop_defs(doc, comp); + let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; + let expected = match def { + PropDef::Boolean { .. } => "visible", + PropDef::Text { .. } => "characters", + PropDef::InstanceSwap { .. } => "mainComponent", + PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), + PropDef::Slot { .. } => SLOT, + }; + let field = field.unwrap_or(expected); + if field != expected { + return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); + } + match field { + "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), + "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), + "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), + SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), + SLOT if doc + .subtree(comp) + .into_iter() + .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => + { + return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); + } + _ => {} + } + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } + Ok(()) +} + +pub(super) fn bind_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.bindProperty"; + let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; + let property = string(p, "property"); + let field = string(p, "field"); + if let Some(f) = &field + && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) + { + return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); + } + let doc = s.doc_mut()?; + bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; + ok() +} + +/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. +fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { + for d in doc.subtree(comp) { + let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); + if refs.values().any(|v| v == old) + && let Some(n) = doc.node_mut(d) + { + match new { + Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), + None => n.prop_refs.retain(|_, v| v != old), + } + } + } + // Instances' values and the variables bound to them follow (a deleted property drops both). + let old_key = format!("{PROP_BINDING}{old}"); + for inst in instances_of(doc, comp) { + let n = doc.get_mut(inst)?; + if let NodeKind::Instance { props, .. } = &mut n.kind + && let Some(v) = props.remove(old) + && let Some(nw) = new + { + props.insert(nw.to_string(), v); + } + if let Some(var) = n.bound.remove(&old_key) + && let Some(nw) = new + { + n.bound.insert(format!("{PROP_BINDING}{nw}"), var); + } + } + Ok(()) +} + +pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.editProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Some(nn) = &new_name { + let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) + || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); + if taken { + return Err(bad(cmd, format!("a property named `{nn}` already exists"))); + } + } + // Variant property of a set. + if let Owner::Set(set) = owner + && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + let rename_value = match (string(p, "value"), string(p, "newValue")) { + (Some(v), Some(nv)) => { + let nv = nv.trim().to_string(); + if !values.contains(&v) { + return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); + } + if nv.is_empty() || nv.contains([',', '=']) { + return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); + } + Some((v, nv)) + } + _ => None, + }; + let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + // Reordered: every variant's name lists the properties in the new order. + if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { + let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; + let key = new_name.clone().unwrap_or(name.clone()); + order.retain(|k| *k != key); + order.insert(to.min(order.len()), key); + } + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + if let Some((v, nv)) = &rename_value + && pairs.get(&name) == Some(v) + { + pairs.insert(name.clone(), nv.clone()); + } + if let Some(nn) = &new_name + && let Some(v) = pairs.remove(&name) + { + pairs.insert(nn.clone(), v); + } + doc.get_mut(*c)?.name = variant_name(&pairs, &order); + } + // The default variant is the top-left one: the variant with that value (and the default's + // other values, when there is one) trades places with it. + if let Some(dv) = string(p, "default") { + let key = new_name.clone().unwrap_or(name.clone()); + let current = default_variant(doc, set); + let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + wanted.insert(key.clone(), dv.clone()); + let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); + let pick = + comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); + let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; + if f != d { + let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); + let (fp, dp) = (place(doc, f), place(doc, d)); + for (c, (x, y)) in [(f, dp), (d, fp)] { + let n = doc.get_mut(c)?; + let [a, b, cc, dd, ..] = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); + } + fit_set(doc, set); + } + } + return ok(); + } + let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; + let preferred = if get(p, "preferred").is_some() { + if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { + return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); + } + Some(preferred_arg(cmd, doc, p)?) + } else { + None + }; + let mut new_def = match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { + *preferred = list; + } + for c in &comps { + let had = prop_defs(doc, *c).contains_key(&name); + if !had { + continue; + } + let key = new_name.clone().unwrap_or(name.clone()); + let nd = new_def.clone(); + let old = name.clone(); + set_prop_defs(doc, *c, move |m| { + m.remove(&old); + m.insert(key, nd); + })?; + if let Some(nn) = &new_name { + rename_prop_refs(doc, *c, &name, Some(nn))?; + } + } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } + ok() +} + +pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.deleteProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Owner::Set(set) = owner + && variant_options(doc, set).iter().any(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + pairs.remove(&name); + let n = doc.get_mut(*c)?; + n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; + } + return ok(); + } + let mut found = false; + for c in &comps { + if prop_defs(doc, *c).contains_key(&name) { + found = true; + set_prop_defs(doc, *c, |m| { + m.remove(&name); + })?; + rename_prop_refs(doc, *c, &name, None)?; + } + } + if !found { + return Err(bad(cmd, format!("no property named `{name}`"))); + } + ok() +} + +fn def_json(name: &str, d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), + PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), + PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), + PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), + } +} + +/// Properties of a component, set or instance as JSON (`component.properties`). +pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let (owner_comp, values) = match &n.kind { + NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), + _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), + }; + let Some(comp) = owner_comp else { return json!({"properties": []}) }; + let mut list: Vec = Vec::new(); + if let Some(set) = variant_set(doc, comp) { + let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); + let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + for (k, vals) in variant_options(doc, set) { + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); + if values.is_some() { + v["value"] = json!(current.get(&k)); + } + list.push(v); + } + } + // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. + let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); + for (k, d) in prop_defs(doc, comp) { + let mut v = def_json(&k, &d); + if !takes_value(&d) { + let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); + if values.is_some() { + v["value"] = layers.first().map_or(Value::Null, |l| { + let count = doc.node(*l).map_or(0, |n| n.children.len()); + json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) + }); + } else { + v["layers"] = json!(layers); + } + } else if let Some(vals) = &values { + v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); + } + list.push(v); + } + let set = variant_set(doc, comp); + let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); + json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) +} diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs new file mode 100644 index 000000000..f6e7ac6cd --- /dev/null +++ b/crates/engine/src/cmd/component/variants.rs @@ -0,0 +1,175 @@ +use super::*; + +/// Wrap components in a new component set (named after the first) placed around them. +pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; + if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); + } + let bounds = doc.bounds_of(ids).unwrap_or_default(); + let pw = doc.world_transform(parent); + let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); + let set = doc.alloc_id(); + let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); + n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); + n.fills.clear(); + doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; + for i in ids { + doc.reparent(*i, set, None)?; + } + Ok(set) +} + +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { + if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None + } +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; + for (k, i) in ids.iter().enumerate() { + let node = doc.get_mut(*i)?; + if !node.name.contains('=') { + node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); + } + } + Ok(set) +} + +pub(super) fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; + s.set_selection(vec![set]); + Ok(json!({"id": set})) +} + +/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). +pub(super) fn fit_set(doc: &mut Document, set: NodeId) { + let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; + let (mut w, mut h) = (0.0f64, 0.0f64); + for c in kids { + if let Some(n) = doc.node(c) { + let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); + w = w.max(b.x1 + 20.0); + h = h.max(b.y1 + 20.0); + } + } + if let Some(n) = doc.node_mut(set) { + n.width = n.width.max(w); + n.height = n.height.max(h); + } +} + +pub(super) fn add_variant(s: &mut Session, p: &Value) -> Result { + let cmd = "component.addVariant"; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; + let doc = s.doc_mut()?; + let (set, base) = match doc.get(t)?.kind { + NodeKind::ComponentSet { .. } => { + let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; + (t, last) + } + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), + }; + let options = variant_options(doc, set); + let mut pairs = variant_props(&doc.get(base)?.name); + let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); + let existing: BTreeSet> = + doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); + match (string(p, "property"), string(p, "value")) { + (Some(prop), Some(value)) => { + let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); + if !options.iter().any(|(k, _)| *k == prop) { + return Err(bad(cmd, format!("unknown variant property `{prop}`"))); + } + if value.is_empty() || value.contains([',', '=']) { + return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); + } + pairs.insert(prop, value); + if existing.contains(&pairs) { + return Err(bad(cmd, "that variant already exists")); + } + } + (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), + _ => { + let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); + let mut k = existing.len() + 1; + loop { + pairs.insert(prop.clone(), format!("Variant{k}")); + if !existing.contains(&pairs) || k > 10_000 { + break; + } + k += 1; + } + } + } + let name = variant_name(&pairs, &order); + let bottom = doc + .get(set)? + .children + .iter() + .filter_map(|c| doc.node(*c)) + .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) + .fold(0.0f64, f64::max); + let dup = doc.duplicate_into(base, set, None)?; + let n = doc.get_mut(dup)?; + n.name = name; + let c = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); + fit_set(doc, set); + s.set_selection(vec![dup]); + Ok(json!({"id": dup})) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 60077efd5..7ec63c698 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,6 +10,22 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; +mod clipboard; +mod props; +mod select; + +pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into}; +use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace}; +use props::{ + batch_rename, effect_add, effect_copy, effect_paste, find_replace, paint_copy, paint_list, paint_paste, paint_set, pattern_fill, reparent, set, + stroke_brush, width_profile, +}; +pub use select::opacity_from_digits; +use select::{ + hide_others, layers_at, nudge, nudge_size, opacity_keys, select_all, select_area, select_children, select_inverse, select_matching, + select_parent, select_same, select_sibling, toggle, +}; + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -536,1054 +552,3 @@ fn bump_name(n: &str) -> String { } format!("{n} 2") } - -/// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { - let ds = s.ds()?; - // Pages are not layers: they can't be pasted anywhere. - let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); - if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); - } - let (nodes, images, bounds) = copied(&ds.doc, &layers); - let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); - s.clipboard.source = Some(source); - s.clipboard.source_path = source_path; - s.clipboard.source_doc = uid; - s.clipboard.copied_components = nodes - .iter() - .filter(|(_, sub)| { - cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) - }) - .map(|(root, _)| *root) - .collect(); - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); - Ok(()) -} - -type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); - -fn copied(doc: &Document, ids: &[NodeId]) -> Copied { - let ids = z_sorted(doc, &roots(doc, ids)); - let mut nodes = Vec::new(); - let mut images = BTreeMap::new(); - for i in &ids { - let world = doc.world_transform(*i); - let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); - // Store the root's transform in page space so pastes land where it was. - if let Some(r) = sub.first_mut() { - r.transform = world; - } - for n in &sub { - for p in n.fills.iter().chain(n.strokes.iter()) { - if let figcraft_color::PaintKind::Image(img) = &p.kind - && let Some(d) = doc.images.get(&img.image) - { - images.insert(img.image.clone(), d.clone()); - } - } - } - nodes.push((*i, sub)); - } - let bounds = doc.bounds_of(&ids); - (nodes, images, bounds) -} - -/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. -pub const CLIP_OPEN: &str = "(figma)"; -pub const CLIP_CLOSE: &str = "(/figma)"; -/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its -/// base64 is checked first, so neither the text nor the decoded bytes can pass it. -const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; -const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; - -/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. -fn clip_base64(text: &str) -> Option> { - use base64::Engine as _; - if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { - return None; - } - let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() -} - -/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in -/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML -/// holds no readable design data (it may just mention the marker). -pub fn design_clipboard_buffer(html: &str) -> Option> { - let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); - let len = html.get(start..)?.find(CLIP_CLOSE)?; - let bytes = clip_base64(html.get(start..start + len)?)?; - figcraft_format::fig::is_fig(&bytes).then_some(bytes) -} - -/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, -/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, -/// store them as a foreign-file clipboard source with a bumped layers generation, then -/// delegate to `paste` and append import warnings. -fn paste_design_layers(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteDesignLayers"; - let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, - (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, - (None, None) => return Err(bad(CMD, "pass `html` or `data`")), - }; - let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; - let doc = std::sync::Arc::new(other.doc); - let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); - if tops.is_empty() { - return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); - } - let (nodes, images, bounds) = copied(&doc, &tops); - // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it - // still pastes afterwards. - let props = s.clipboard.props.take(); - let paint = s.clipboard.paint.take(); - let stroke = s.clipboard.stroke.take(); - let effect = s.clipboard.effect.take(); - // No open file has id 0. So paste treats this copy as a copy from a different file. - let layers_generation = s.clipboard.layers_generation.wrapping_add(1); - s.clipboard = crate::Clipboard { - nodes, - images, - props, - paint, - stroke, - effect, - bounds, - source_doc: 0, - source: Some(doc), - source_path: None, - copied_components: BTreeSet::new(), - layers_generation, - }; - let mut out = paste(s, p)?; - if !other.issues.is_empty() { - out["warnings"] = json!(other.issues); - } - Ok(out) -} - -fn copy(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, "edit.copy", &ids)?; - Ok(json!({"copied": s.clipboard.nodes.len()})) -} - -fn cut(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, "edit.cut", &ids)?; - let doc = s.doc()?; - let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); - delete(s, &json!({"ids": layers})) -} - -/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). -pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { - let clip = s.clipboard.clone(); - if clip.nodes.is_empty() { - return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); - } - // From another file: its instances follow the mains this file can have. - let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); - let copy_local = s.ds()?.uid == clip.source_doc; - let doc = s.doc_mut()?; - for (k, v) in &clip.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - // Sections go on the page (or in sections), never in the frame being pasted into. - let page = doc.page_of(parent).unwrap_or(parent); - let shift = match (at, clip.bounds) { - (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), - _ => Affine::IDENTITY, - }; - let mut out = Vec::new(); - let mut all = BTreeMap::new(); - for (root, nodes) in &clip.nodes { - if copy_local - && clip.copied_components.contains(root) - && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) - && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) - { - let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; - doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; - out.push(instance); - continue; - } - let mut map = BTreeMap::new(); - for n in nodes { - map.insert(n.id, doc.alloc_id()); - } - let mut nodes = nodes.clone(); - let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); - let into = if section && !doc.takes_sections(parent) { page } else { parent }; - if let Some(r) = nodes.first_mut() { - r.transform = doc.world_transform(into).inverse() * shift * r.transform; - } - out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); - all.extend(map); - } - // Board connectors pasted with what they join join the pasted copies. - let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = - clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); - crate::board::rejoin(doc, &originals, &all, !copy_local); - if let Some(src) = foreign { - crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; - } - Ok(out) -} - -fn paste(s: &mut Session, p: &Value) -> Result { - let page = s.page()?; - // Figma pastes into the selected frame (if one is selected), at the same position when it fits, - // else in the frame's centre; otherwise in place on the page (or the viewport centre). - let sel = s.selection(); - let doc = s.doc()?; - let copy_local = s.ds()?.uid == s.clipboard.source_doc; - let target = id(p, "parent").or_else(|| { - sel.first() - .copied() - .filter(|i| { - sel.len() == 1 - && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) - }) - .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) - }); - let parent = target.unwrap_or(page); - let at = match (num(p, "x"), num(p, "y")) { - (Some(x), Some(y)) => Some((x, y)), - _ => { - let b = s.clipboard.bounds; - match (target, b) { - (Some(t), Some(b)) => { - let tb = doc.world_bounds(t); - match tb { - Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, - Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), - None => None, - } - } - (None, Some(b)) => { - // Off-screen originals paste at the viewport centre. - let v = &s.ds()?.view; - let (ox, oy) = v.origin(); - let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); - if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } - } - _ => None, - } - } - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_over(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc()?; - let (parent, at) = match sel.first() { - Some(i) => { - let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); - (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) - } - None => (page, None), - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_replace(s: &mut Session, p: &Value) -> Result { - let targets = req_targets("edit.pasteReplace", s, p)?; - let mut out = Vec::new(); - for t in targets { - let doc = s.doc()?; - let Some(n) = doc.node(t) else { continue }; - let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; - let idx = doc.index_in_parent(t); - let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); - let ids = paste_into(s, parent, at)?; - let doc = s.doc_mut()?; - for (k, i) in ids.iter().enumerate() { - if let Some(ix) = idx { - let _ = doc.reorder(*i, ix + k); - } - } - doc.remove(t)?; - out.extend(ids); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn copy_props_of(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let mut v = json!({ - "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, - "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, - "cornerSmoothing": n.corners.smoothing, - }); - if let NodeKind::Text(t) = &n.kind { - v["fontFamily"] = json!(t.style.font_family); - v["fontStyle"] = json!(t.style.font_style); - v["fontSize"] = json!(t.style.font_size); - v["fontVariations"] = json!(t.style.variations); - v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); - v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); - } - v -} - -fn copy_props(s: &mut Session, p: &Value) -> Result { - let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; - let v = copy_props_of(s.doc()?, id); - s.clipboard.props = Some(v.clone()); - Ok(v) -} - -fn paste_props(s: &mut Session, p: &Value) -> Result { - let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; - let ids = req_targets("edit.pasteProperties", s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let mut m = props.as_object().cloned().unwrap_or_default(); - let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); - if !is_text { - m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); - } - if !doc.get(i)?.kind.has_corner_radius() { - m.remove("cornerRadius"); - m.remove("cornerSmoothing"); - } - super::props::apply("edit.pasteProperties", doc, i, &m)?; - } - ok() -} - -fn layers_at(s: &mut Session, p: &Value) -> Result { - let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); - let doc = s.doc()?; - let zoom = s.ds()?.view.zoom.max(1e-6); - let sel = s.selection(); - let result = crate::booleans::result_outline(doc, &sel); - let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) - .into_iter() - .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) - .collect(); - Ok(json!({"layers": layers})) -} - -fn select_all(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - // Select all siblings of the current selection, else the page's top level. - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// What Select all would select, less the selection (hidden and locked layers neither). -fn select_inverse(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = - doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. -fn select_area(s: &mut Session, p: &Value) -> Result { - let cmd = "select.area"; - let keys = ["x", "y", "width", "height"]; - if keys.iter().all(|k| p.get(*k).is_none()) { - s.request_ui("selectArea", json!({})); - return ok(); - } - let [x, y, w, h] = keys.map(|k| num(p, k)); - let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; - if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { - return Err(bad(cmd, "the box is out of range")); - } - let start = figcraft_geom::Point::new(x, y); - let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); - let add = boolean(p, "add") == Some(true); - let base = if add { s.selection() } else { Vec::new() }; - let within = s.marquee_within(start)?; - let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; - s.set_selection(sel.clone()); - Ok(json!({"selection": sel})) -} - -fn select_children(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids = Vec::new(); - for i in s.selection() { - if let Some(n) = doc.node(i) { - ids.extend(n.children.iter().copied()); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_parent(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids: Vec = Vec::new(); - for i in s.selection() { - if let Some(p) = doc.node(i).and_then(|n| n.parent) - && !doc.node(p).is_some_and(Node::is_page) - && !ids.contains(&p) - { - ids.push(p); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_sibling(s: &mut Session, dir: i64) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(cur) = sel.last().copied() else { - let first = doc.get(s.page()?)?.children.last().copied(); - s.set_selection(first.into_iter().collect()); - return Ok(json!({"selection": s.selection()})); - }; - let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; - let kids = &doc.get(parent)?.children; - let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; - let n = kids.len() as i64; - // Layers panel order is reversed (top of the list = last child): Tab goes down the list. - let next = (idx - dir).rem_euclid(n.max(1)) as usize; - let id = kids.get(next).copied().unwrap_or(cur); - s.set_selection(vec![id]); - Ok(json!({"selection": [id]})) -} - -fn select_matching(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; - let (name, ty) = (first.name.clone(), first.kind.type_name()); - let page = s.page()?; - let ids: Vec = - doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; - let page = s.page()?; - let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in -/// quick succession the exact percentage. -pub fn opacity_from_digits(digits: &str) -> Option { - let mut n = 0u32; - let mut count = 0; - for c in digits.chars() { - // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. - n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; - count += 1; - } - match count { - 1 if n == 0 => Some(1.0), - 1 => Some(n as f32 / 10.0), - 2 => Some(n as f32 / 100.0), - _ => None, - } -} - -fn opacity_keys(s: &mut Session, p: &Value) -> Result { - let digits = req_string("object.opacityKeys", p, "digits")?; - let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - let ids = req_targets("object.opacityKeys", s, p)?; - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - doc.get_mut(i)?.opacity = o; - } - super::styles::detach_edited(doc, &before); - Ok(json!({"opacity": o})) -} - -fn nudge(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); - let (dx, dy) = (ux * k, uy * k); - let ids = req_targets("transform.nudge", s, p)?; - // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds - // no undo step. - let mut work = s.doc()?.clone(); - let doc = &mut work; - let ids = roots(doc, &ids); - let mut changed = false; - // Children in an auto layout flow move one place instead (see `al_keys`). - for i in &ids { - if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { - let n = doc.get_mut(*i)?; - n.transform = Affine::translate((dx, dy)) * n.transform; - changed = true; - } - } - for dir in crate::al_keys::Dir::from_units(ux, uy) { - // Grouped per arrow: a move into a neighbouring frame changes the parent. - let mut groups: BTreeMap> = BTreeMap::new(); - for i in &ids { - if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { - groups.entry(parent).or_default().push(*i); - } - } - for (parent, block) in groups { - changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; - } - } - if changed { - s.ds_mut()?.doc = std::sync::Arc::new(work); - } - ok() -} - -fn nudge_size(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); - let ids = req_targets("transform.nudgeSize", s, p)?; - let ids = roots(s.doc()?, &ids); - for i in ids { - let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; - let mut q = serde_json::Map::new(); - q.insert("ids".into(), json!([i])); - // Only the axis that changes becomes fixed. - if dw != 0.0 { - q.insert("width".into(), json!((w + dw).max(0.0))); - } - if dh != 0.0 { - q.insert("height".into(), json!((h + dh).max(0.0))); - } - super::arrange::resize_cmd(s, &Value::Object(q))?; - } - ok() -} - -fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { - let ids = req_targets("object.toggle", s, p)?; - let doc = s.doc_mut()?; - let explicit = boolean(p, what); - // Figma: if any is visible, hide all (and vice versa). - let value = explicit.unwrap_or_else(|| match what { - "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), - _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), - }); - for i in &ids { - let n = doc.get_mut(*i)?; - if what == "visible" { - n.visible = value; - } else { - n.locked = value; - } - } - if what == "locked" && value { - s.set_selection(vec![]); - } - Ok(json!({what: value})) -} - -fn hide_others(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc_mut()?; - let keep: BTreeSet = sel - .iter() - .flat_map(|i| { - let mut v = doc.ancestors(*i); - v.push(*i); - v - }) - .collect(); - let tops: Vec = doc.get(page)?.children.clone(); - for t in tops { - if !keep.contains(&t) { - doc.get_mut(t)?.visible = false; - } - } - ok() -} - -fn batch_rename(s: &mut Session, p: &Value) -> Result { - let cmd = "object.batchRename"; - let ids = req_targets(cmd, s, p)?; - let find = string(p, "find"); - let replace = string(p, "replace").unwrap_or_default(); - let pattern = string(p, "name"); - let rename = - crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) - .map_err(|e| bad(cmd, e))?; - let doc = s.doc_mut()?; - let names = rename.plan(doc, &ids); - for (i, name) in &names { - doc.get_mut(*i)?.name = name.clone(); - } - Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) -} - -/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. -/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. -fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { - if needle.is_empty() { - return None; - } - (from..hay.len()) - .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) -} - -fn find_replace(s: &mut Session, p: &Value) -> Result { - let find = req_string("edit.findReplace", p, "find")?; - if find.is_empty() { - return Err(bad("edit.findReplace", "`find` is empty")); - } - // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). - let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); - let names = boolean(p, "layerNames").unwrap_or(false); - let page = s.page()?; - let doc = s.doc_mut()?; - let mut hits = Vec::new(); - let needle: Vec = find.chars().collect(); - let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); - for i in doc.subtree(page) { - let Some(n) = doc.node(i) else { continue }; - let text: Option> = match &n.kind { - NodeKind::Text(t) => Some(t.characters.chars().collect()), - _ => None, - }; - let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); - let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); - if !(text_hit || name_hit) || n.is_page() { - continue; - } - hits.push(i); - if let Some(r) = &replace { - let n = doc.get_mut(i)?; - if names && name_hit { - let chars: Vec = n.name.chars().collect(); - let mut out = String::new(); - let mut at = 0; - while let Some(pos) = find_ci(&chars, &needle, at) { - out.extend(chars.get(at..pos).unwrap_or_default()); - out.push_str(r); - at = pos + needle.len(); - } - out.extend(chars.get(at..).unwrap_or_default()); - n.name = out; - } - if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { - // Replace occurrence by occurrence so styles shift correctly. The search resumes - // after each inserted replacement, so a replacement that matches `find` (in any - // case) neither loops forever nor stops the other occurrences. - let mut cursor = 0; - for _ in 0..10_000 { - let Some(start) = find_ci(&chars, &needle, cursor) else { break }; - t.replace_range(start, start + needle.len(), r); - chars.splice(start..start + needle.len(), r_chars.iter().copied()); - cursor = start + r_chars.len(); - } - } - } - } - if boolean(p, "select").unwrap_or(true) { - s.set_selection(hits.clone()); - } - Ok(json!({"matches": hits})) -} - -/// Keys only some layer types take, the test for those layers, and why the others skip them. -type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); - -fn set(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.set", s, p)?; - let props = match get(p, "props") { - Some(Value::Object(m)) => m.clone(), - _ => { - // Allow flat params: {"id": 3, "fill": "#f00"}. - let mut m = p.as_object().cloned().unwrap_or_default(); - m.remove("id"); - m.remove("ids"); - m - } - }; - if props.is_empty() { - return Err(bad("node.set", "no properties given")); - } - // A new rotation for the selection turns it about its moved rotation origin: each layer's - // point under the origin stays put. - let ds = s.ds()?; - let pivot = - ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); - let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { - Some(o) => roots(&ds.doc, &ids) - .into_iter() - .map(|i| (i, ds.doc.world_transform(i))) - .filter(|(_, w)| w.determinant().abs() > 1e-12) - .map(|(i, w)| (i, w.inverse() * o)) - .collect(), - None => Vec::new(), - }; - let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Type-specific keys go to the layers that take them and are skipped, with a warning, on the - // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets - // that take them, the first refuses them (`props::layout_mut`, `props::apply`). - let doc = &s.ds()?.doc; - let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); - let frame_like = |n: &Node| n.kind.is_frame_like(); - let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); - let typed: [TypedKeys; 2] = [ - (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), - (&path_keys, &vector, "only vector layers have a path"), - ]; - let mut skipped: BTreeMap> = BTreeMap::new(); - for (keys, takes, why) in typed { - let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); - if keys.is_empty() || !ids.iter().any(takes) { - continue; - } - let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); - if others.is_empty() { - continue; - } - for i in &others { - skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); - } - let names: Vec = - others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: {why}", - keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); - } - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; - } - super::styles::detach_edited(doc, &before); - if let Some(o) = pivot { - for (i, q) in pinned { - let d = o - doc.world_transform(i) * q; - if d.hypot().is_finite() { - super::arrange::move_world(doc, i, d.x, d.y)?; - } - } - } - if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } -} - -/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that -/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is -/// Bold), say which face it is. Families that aren't installed, and names that match a style or a -/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. -fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { - let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; - let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { - Some(f) => vec![f.to_string()], - None => ids - .iter() - .filter_map(|i| doc.node(*i)) - .filter_map(|n| n.kind.text()) - .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) - .collect(), - }; - families.sort(); - families.dedup(); - let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); - let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); - families - .into_iter() - .filter_map(|family| { - let styles = figcraft_text::family_styles(&family); - if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { - return None; - } - let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; - let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); - if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { - return None; - } - Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) - }) - .collect() -} - -/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances -/// record the override). -fn width_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.widthProfile"; - let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); - let ids = req_targets(cmd, s, p)?; - let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); - let doc = s.doc_mut()?; - for i in ids { - super::props::apply(cmd, doc, i, &props)?; - } - ok() -} - -fn stroke_brush(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.brush"; - let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; - let brush = super::props::brush_of(&v).ok_or_else(|| bad(cmd, super::props::BRUSH_HELP))?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - if brush.is_some() && n.strokes.is_empty() { - n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); - } - n.stroke.brush = brush.clone(); - } - ok() -} - -fn reparent(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.reparent", s, p)?; - let parent = req_id("node.reparent", p, "parent")?; - let index = num(p, "index").map(|i| i.max(0.0) as usize); - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - // `index` is where the first moved layer ends up among the parent's children (bottom first), - // counted after the move, the way `node.reorder` counts it. The layers land together, keeping - // their order, under the sibling that stays put and ends up just after them (none: on top). - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); - for i in &ids { - // Counted with the layer still in place, as `reparent` expects. - let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); - doc.reparent(*i, parent, at)?; - } - ok() -} - -fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { - let cmd = if fills { "fill" } else { "stroke" }; - let ids = req_targets(cmd, s, p)?; - let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let frameish = n.kind.is_frame_like(); - let list = if fills { &mut n.fills } else { &mut n.strokes }; - if add { - let paint = match get(p, "paint").or_else(|| get(p, "color")) { - Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, - None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), - None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), - None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), - }; - list.push(paint); - } else { - let idx = index.unwrap_or(list.len().saturating_sub(1)); - // The paint's colour binding goes with it; later paints' move down one. - super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx); - } - } - ok() -} - -fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.set" } else { "stroke.set" }; - let ids = req_targets(cmd, s, p)?; - let idx = opt_index(cmd, p, "index")?; - let new_paint = match get(p, "paint") { - Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), - None => None, - }; - let col = color(p, "color"); - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let list = if fills { &mut n.fills } else { &mut n.strokes }; - let at = idx.unwrap_or(list.len().saturating_sub(1)); - if list.is_empty() { - list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); - } - let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; - let old = slot.clone(); - if let Some(np) = &new_paint { - *slot = np.clone(); - } - if let Some(c) = col { - slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; - if c.a < 1.0 { - slot.opacity = c.a; - slot.color_alpha = c.a; - } - } - if let Some(o) = num(p, "opacity") { - slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; - slot.color_alpha = 1.0; - } - if let Some(v) = boolean(p, "visible") { - slot.visible = v; - } - if let Some(b) = blend(p, "blend") { - slot.blend = b; - } - super::lists::rebind_edited_paint(n, fills, at, &old); - if fills { - n.styles.fill = None; - } else { - n.styles.stroke = None; - } - } - ok() -} - -/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke -/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. -fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.copy" } else { "stroke.copy" }; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let at = opt_index(cmd, p, "index")?; - let (paint, stroke) = { - let doc = s.doc()?; - let n = doc.get(id)?; - let list = if fills { &n.fills } else { &n.strokes }; - let at = at.unwrap_or(list.len().saturating_sub(1)); - let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; - (paint, (!fills).then(|| n.stroke.clone())) - }; - s.clipboard.paint = Some((fills, paint.clone())); - s.clipboard.stroke = stroke; - serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) -} - -/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A -/// stroke's copied settings go onto each target too. -fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.paste" } else { "stroke.paste" }; - let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { - return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); - }; - if copied_fills != fills { - let what = if fills { "stroke" } else { "fill" }; - return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); - } - let ids = req_targets(cmd, s, p)?; - // A copied pattern pointing at a target or a layer inside it would make the layer pattern - // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. - if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { - let doc = s.doc()?; - if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { - return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); - } - } - let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); - let out = match opt_index(cmd, p, "index")? { - Some(at) => { - q["index"] = json!(at); - paint_set(s, &q, fills)? - } - None => paint_list(s, &q, fills, true)?, - }; - if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { - for id in ids { - s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); - } - } - Ok(out) -} - -fn pattern_fill(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "fill.pattern"; - let src = req_id(CMD, p, "source")?; - let doc = s.doc()?; - if doc.node(src).is_none_or(|n| n.is_page()) { - return Err(bad(CMD, "`source` must be a layer")); - } - if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { - return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); - } - let mut pt = figcraft_color::PatternPaint::new(src.0); - pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); - pt.align = parse(CMD, p, "align")?.unwrap_or_default(); - pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); - let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; - pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - q["paint"] = - serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) - .map_err(|e| bad(CMD, e.to_string()))?; - paint_set(s, &q, true) -} - -fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { - Some(e) => e, - None => { - let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); - Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? - } - }; - for i in req_targets("effect.add", s, p)? { - s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); - } - ok() -} - -/// Copy effect `index` (the top one by default) of the first target into the clipboard. -fn effect_copy(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.copy"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; - let at = opt_index(CMD, p, "index")?; - let e = { - let doc = s.doc()?; - let list = &doc.get(id)?.effects; - let at = at.unwrap_or(list.len().saturating_sub(1)); - list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? - }; - s.clipboard.effect = Some(e.clone()); - serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) -} - -/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. -fn effect_paste(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.paste"; - let Some(e) = s.clipboard.effect.clone() else { - return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); - }; - let at = opt_index(CMD, p, "index")?; - for id in req_targets(CMD, s, p)? { - let n = s.doc_mut()?.get_mut(id)?; - match at { - Some(i) => { - if let Some(slot) = n.effects.get_mut(i) { - *slot = e.clone(); - } - } - None => n.effects.push(e.clone()), - } - } - ok() -} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 000000000..d0e7b2dcf --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,332 @@ +use super::*; + +/// The copied form of the selection. +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { + let ds = s.ds()?; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { + let ids = z_sorted(doc, &roots(doc, ids)); + let mut nodes = Vec::new(); + let mut images = BTreeMap::new(); + for i in &ids { + let world = doc.world_transform(*i); + let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); + // Store the root's transform in page space so pastes land where it was. + if let Some(r) = sub.first_mut() { + r.transform = world; + } + for n in &sub { + for p in n.fills.iter().chain(n.strokes.iter()) { + if let figcraft_color::PaintKind::Image(img) = &p.kind + && let Some(d) = doc.images.get(&img.image) + { + images.insert(img.image.clone(), d.clone()); + } + } + } + nodes.push((*i, sub)); + } + let bounds = doc.bounds_of(&ids); + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; + +pub const CLIP_CLOSE: &str = "(/figma)"; + +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; + +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. +pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = crate::cmd::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it + // still pastes afterwards. + let props = s.clipboard.props.take(); + let paint = s.clipboard.paint.take(); + let stroke = s.clipboard.stroke.take(); + let effect = s.clipboard.effect.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + paint, + stroke, + effect, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) +} + +pub(super) fn copy(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.copy", s, p)?; + copy_to_clipboard(s, "edit.copy", &ids)?; + Ok(json!({"copied": s.clipboard.nodes.len()})) +} + +pub(super) fn cut(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.cut", s, p)?; + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) +} + +/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). +pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { + let clip = s.clipboard.clone(); + if clip.nodes.is_empty() { + return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); + } + // From another file: its instances follow the mains this file can have. + let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; + let doc = s.doc_mut()?; + for (k, v) in &clip.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + // Sections go on the page (or in sections), never in the frame being pasted into. + let page = doc.page_of(parent).unwrap_or(parent); + let shift = match (at, clip.bounds) { + (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), + _ => Affine::IDENTITY, + }; + let mut out = Vec::new(); + let mut all = BTreeMap::new(); + for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } + let mut map = BTreeMap::new(); + for n in nodes { + map.insert(n.id, doc.alloc_id()); + } + let mut nodes = nodes.clone(); + let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); + let into = if section && !doc.takes_sections(parent) { page } else { parent }; + if let Some(r) = nodes.first_mut() { + r.transform = doc.world_transform(into).inverse() * shift * r.transform; + } + out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); + all.extend(map); + } + // Board connectors pasted with what they join join the pasted copies. + let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = + clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); + crate::board::rejoin(doc, &originals, &all, !copy_local); + if let Some(src) = foreign { + crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; + } + Ok(out) +} + +pub(super) fn paste(s: &mut Session, p: &Value) -> Result { + let page = s.page()?; + // Figma pastes into the selected frame (if one is selected), at the same position when it fits, + // else in the frame's centre; otherwise in place on the page (or the viewport centre). + let sel = s.selection(); + let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; + let target = id(p, "parent").or_else(|| { + sel.first() + .copied() + .filter(|i| { + sel.len() == 1 + && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) + }) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) + }); + let parent = target.unwrap_or(page); + let at = match (num(p, "x"), num(p, "y")) { + (Some(x), Some(y)) => Some((x, y)), + _ => { + let b = s.clipboard.bounds; + match (target, b) { + (Some(t), Some(b)) => { + let tb = doc.world_bounds(t); + match tb { + Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, + Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), + None => None, + } + } + (None, Some(b)) => { + // Off-screen originals paste at the viewport centre. + let v = &s.ds()?.view; + let (ox, oy) = v.origin(); + let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); + if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } + } + _ => None, + } + } + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_over(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc()?; + let (parent, at) = match sel.first() { + Some(i) => { + let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); + (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) + } + None => (page, None), + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_replace(s: &mut Session, p: &Value) -> Result { + let targets = req_targets("edit.pasteReplace", s, p)?; + let mut out = Vec::new(); + for t in targets { + let doc = s.doc()?; + let Some(n) = doc.node(t) else { continue }; + let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; + let idx = doc.index_in_parent(t); + let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); + let ids = paste_into(s, parent, at)?; + let doc = s.doc_mut()?; + for (k, i) in ids.iter().enumerate() { + if let Some(ix) = idx { + let _ = doc.reorder(*i, ix + k); + } + } + doc.remove(t)?; + out.extend(ids); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let mut v = json!({ + "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, + "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, + "cornerSmoothing": n.corners.smoothing, + }); + if let NodeKind::Text(t) = &n.kind { + v["fontFamily"] = json!(t.style.font_family); + v["fontStyle"] = json!(t.style.font_style); + v["fontSize"] = json!(t.style.font_size); + v["fontVariations"] = json!(t.style.variations); + v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); + v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); + } + v +} + +pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result { + let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; + let v = copy_props_of(s.doc()?, id); + s.clipboard.props = Some(v.clone()); + Ok(v) +} + +pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result { + let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; + let ids = req_targets("edit.pasteProperties", s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let mut m = props.as_object().cloned().unwrap_or_default(); + let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); + if !is_text { + m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); + } + if !doc.get(i)?.kind.has_corner_radius() { + m.remove("cornerRadius"); + m.remove("cornerSmoothing"); + } + crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?; + } + ok() +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs new file mode 100644 index 000000000..f793b50ce --- /dev/null +++ b/crates/engine/src/cmd/edit/props.rs @@ -0,0 +1,458 @@ +use super::*; + +pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { + let cmd = "object.batchRename"; + let ids = req_targets(cmd, s, p)?; + let find = string(p, "find"); + let replace = string(p, "replace").unwrap_or_default(); + let pattern = string(p, "name"); + let rename = + crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) + .map_err(|e| bad(cmd, e))?; + let doc = s.doc_mut()?; + let names = rename.plan(doc, &ids); + for (i, name) in &names { + doc.get_mut(*i)?.name = name.clone(); + } + Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) +} + +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + +pub(super) fn find_replace(s: &mut Session, p: &Value) -> Result { + let find = req_string("edit.findReplace", p, "find")?; + if find.is_empty() { + return Err(bad("edit.findReplace", "`find` is empty")); + } + // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). + let replace = string(p, "replace").map(|r| crate::cmd::props::normalize_line_endings(&r)); + let names = boolean(p, "layerNames").unwrap_or(false); + let page = s.page()?; + let doc = s.doc_mut()?; + let mut hits = Vec::new(); + let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); + for i in doc.subtree(page) { + let Some(n) = doc.node(i) else { continue }; + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); + if !(text_hit || name_hit) || n.is_page() { + continue; + } + hits.push(i); + if let Some(r) = &replace { + let n = doc.get_mut(i)?; + if names && name_hit { + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; + } + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. + let mut cursor = 0; + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); + } + } + } + } + if boolean(p, "select").unwrap_or(true) { + s.set_selection(hits.clone()); + } + Ok(json!({"matches": hits})) +} + +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + +pub(super) fn set(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.set", s, p)?; + let props = match get(p, "props") { + Some(Value::Object(m)) => m.clone(), + _ => { + // Allow flat params: {"id": 3, "fill": "#f00"}. + let mut m = p.as_object().cloned().unwrap_or_default(); + m.remove("id"); + m.remove("ids"); + m + } + }; + if props.is_empty() { + return Err(bad("node.set", "no properties given")); + } + // A new rotation for the selection turns it about its moved rotation origin: each layer's + // point under the origin stays put. + let ds = s.ds()?; + let pivot = + ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); + let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { + Some(o) => roots(&ds.doc, &ids) + .into_iter() + .map(|i| (i, ds.doc.world_transform(i))) + .filter(|(_, w)| w.determinant().abs() > 1e-12) + .map(|(i, w)| (i, w.inverse() * o)) + .collect(), + None => Vec::new(), + }; + let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; + let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| crate::cmd::props::LAYOUT_KEYS.contains(k)).collect(); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; + } + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; + } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); + } + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; + } + crate::cmd::styles::detach_edited(doc, &before); + if let Some(o) = pivot { + for (i, q) in pinned { + let d = o - doc.world_transform(i) * q; + if d.hypot().is_finite() { + crate::cmd::arrange::move_world(doc, i, d.x, d.y)?; + } + } + } + if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } +} + +/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that +/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is +/// Bold), say which face it is. Families that aren't installed, and names that match a style or a +/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. +fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { + let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; + let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { + Some(f) => vec![f.to_string()], + None => ids + .iter() + .filter_map(|i| doc.node(*i)) + .filter_map(|n| n.kind.text()) + .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) + .collect(), + }; + families.sort(); + families.dedup(); + let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); + let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); + families + .into_iter() + .filter_map(|family| { + let styles = figcraft_text::family_styles(&family); + if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { + return None; + } + let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; + let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); + if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { + return None; + } + Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) + }) + .collect() +} + +/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances +/// record the override). +pub(super) fn width_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.widthProfile"; + let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); + let ids = req_targets(cmd, s, p)?; + let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); + let doc = s.doc_mut()?; + for i in ids { + crate::cmd::props::apply(cmd, doc, i, &props)?; + } + ok() +} + +pub(super) fn stroke_brush(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.brush"; + let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; + let brush = crate::cmd::props::brush_of(&v).ok_or_else(|| bad(cmd, crate::cmd::props::BRUSH_HELP))?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + if brush.is_some() && n.strokes.is_empty() { + n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); + } + n.stroke.brush = brush.clone(); + } + ok() +} + +pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.reparent", s, p)?; + let parent = req_id("node.reparent", p, "parent")?; + let index = num(p, "index").map(|i| i.max(0.0) as usize); + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); + for i in &ids { + // Counted with the layer still in place, as `reparent` expects. + let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); + doc.reparent(*i, parent, at)?; + } + ok() +} + +pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { + let cmd = if fills { "fill" } else { "stroke" }; + let ids = req_targets(cmd, s, p)?; + let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let frameish = n.kind.is_frame_like(); + let list = if fills { &mut n.fills } else { &mut n.strokes }; + if add { + let paint = match get(p, "paint").or_else(|| get(p, "color")) { + Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, + None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), + None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), + None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), + }; + list.push(paint); + } else { + let idx = index.unwrap_or(list.len().saturating_sub(1)); + // The paint's colour binding goes with it; later paints' move down one. + crate::cmd::lists::remove_item(n, if fills { crate::cmd::lists::BoundList::Fills } else { crate::cmd::lists::BoundList::Strokes }, idx); + } + } + ok() +} + +pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.set" } else { "stroke.set" }; + let ids = req_targets(cmd, s, p)?; + let idx = opt_index(cmd, p, "index")?; + let new_paint = match get(p, "paint") { + Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), + None => None, + }; + let col = color(p, "color"); + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let list = if fills { &mut n.fills } else { &mut n.strokes }; + let at = idx.unwrap_or(list.len().saturating_sub(1)); + if list.is_empty() { + list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); + } + let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; + let old = slot.clone(); + if let Some(np) = &new_paint { + *slot = np.clone(); + } + if let Some(c) = col { + slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; + if c.a < 1.0 { + slot.opacity = c.a; + slot.color_alpha = c.a; + } + } + if let Some(o) = num(p, "opacity") { + slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; + } + if let Some(v) = boolean(p, "visible") { + slot.visible = v; + } + if let Some(b) = blend(p, "blend") { + slot.blend = b; + } + crate::cmd::lists::rebind_edited_paint(n, fills, at, &old); + if fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + } + ok() +} + +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + // A copied pattern pointing at a target or a layer inside it would make the layer pattern + // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. + if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { + let doc = s.doc()?; + if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { + return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); + } + } + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + +pub(super) fn pattern_fill(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "fill.pattern"; + let src = req_id(CMD, p, "source")?; + let doc = s.doc()?; + if doc.node(src).is_none_or(|n| n.is_page()) { + return Err(bad(CMD, "`source` must be a layer")); + } + if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { + return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); + } + let mut pt = figcraft_color::PatternPaint::new(src.0); + pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); + pt.align = parse(CMD, p, "align")?.unwrap_or_default(); + pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); + let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; + pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["paint"] = + serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) + .map_err(|e| bad(CMD, e.to_string()))?; + paint_set(s, &q, true) +} + +pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result { + let e: Effect = match crate::cmd::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, + None => { + let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); + Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? + } + }; + for i in req_targets("effect.add", s, p)? { + s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); + } + ok() +} + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +pub(super) fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +pub(super) fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs new file mode 100644 index 000000000..03bba8907 --- /dev/null +++ b/crates/engine/src/cmd/edit/select.rs @@ -0,0 +1,267 @@ +use super::*; + +pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { + let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); + let doc = s.doc()?; + let zoom = s.ds()?.view.zoom.max(1e-6); + let sel = s.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) + .into_iter() + .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) + .collect(); + Ok(json!({"layers": layers})) +} + +pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + // Select all siblings of the current selection, else the page's top level. + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// What Select all would select, less the selection (hidden and locked layers neither). +pub(super) fn select_inverse(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = + doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. +pub(super) fn select_area(s: &mut Session, p: &Value) -> Result { + let cmd = "select.area"; + let keys = ["x", "y", "width", "height"]; + if keys.iter().all(|k| p.get(*k).is_none()) { + s.request_ui("selectArea", json!({})); + return ok(); + } + let [x, y, w, h] = keys.map(|k| num(p, k)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; + if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return Err(bad(cmd, "the box is out of range")); + } + let start = figcraft_geom::Point::new(x, y); + let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); + let add = boolean(p, "add") == Some(true); + let base = if add { s.selection() } else { Vec::new() }; + let within = s.marquee_within(start)?; + let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; + s.set_selection(sel.clone()); + Ok(json!({"selection": sel})) +} + +pub(super) fn select_children(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids = Vec::new(); + for i in s.selection() { + if let Some(n) = doc.node(i) { + ids.extend(n.children.iter().copied()); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_parent(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids: Vec = Vec::new(); + for i in s.selection() { + if let Some(p) = doc.node(i).and_then(|n| n.parent) + && !doc.node(p).is_some_and(Node::is_page) + && !ids.contains(&p) + { + ids.push(p); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_sibling(s: &mut Session, dir: i64) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(cur) = sel.last().copied() else { + let first = doc.get(s.page()?)?.children.last().copied(); + s.set_selection(first.into_iter().collect()); + return Ok(json!({"selection": s.selection()})); + }; + let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; + let kids = &doc.get(parent)?.children; + let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; + let n = kids.len() as i64; + // Layers panel order is reversed (top of the list = last child): Tab goes down the list. + let next = (idx - dir).rem_euclid(n.max(1)) as usize; + let id = kids.get(next).copied().unwrap_or(cur); + s.set_selection(vec![id]); + Ok(json!({"selection": [id]})) +} + +pub(super) fn select_matching(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; + let (name, ty) = (first.name.clone(), first.kind.type_name()); + let page = s.page()?; + let ids: Vec = + doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; + let page = s.page()?; + let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in +/// quick succession the exact percentage. +pub fn opacity_from_digits(digits: &str) -> Option { + let mut n = 0u32; + let mut count = 0; + for c in digits.chars() { + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; + count += 1; + } + match count { + 1 if n == 0 => Some(1.0), + 1 => Some(n as f32 / 10.0), + 2 => Some(n as f32 / 100.0), + _ => None, + } +} + +pub(super) fn opacity_keys(s: &mut Session, p: &Value) -> Result { + let digits = req_string("object.opacityKeys", p, "digits")?; + let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; + } + crate::cmd::styles::detach_edited(doc, &before); + Ok(json!({"opacity": o})) +} + +pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + let (dx, dy) = (ux * k, uy * k); + let ids = req_targets("transform.nudge", s, p)?; + // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds + // no undo step. + let mut work = s.doc()?.clone(); + let doc = &mut work; + let ids = roots(doc, &ids); + let mut changed = false; + // Children in an auto layout flow move one place instead (see `al_keys`). + for i in &ids { + if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { + let n = doc.get_mut(*i)?; + n.transform = Affine::translate((dx, dy)) * n.transform; + changed = true; + } + } + for dir in crate::al_keys::Dir::from_units(ux, uy) { + // Grouped per arrow: a move into a neighbouring frame changes the parent. + let mut groups: BTreeMap> = BTreeMap::new(); + for i in &ids { + if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { + groups.entry(parent).or_default().push(*i); + } + } + for (parent, block) in groups { + changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; + } + } + if changed { + s.ds_mut()?.doc = std::sync::Arc::new(work); + } + ok() +} + +pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); + let ids = req_targets("transform.nudgeSize", s, p)?; + let ids = roots(s.doc()?, &ids); + for i in ids { + let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; + let mut q = serde_json::Map::new(); + q.insert("ids".into(), json!([i])); + // Only the axis that changes becomes fixed. + if dw != 0.0 { + q.insert("width".into(), json!((w + dw).max(0.0))); + } + if dh != 0.0 { + q.insert("height".into(), json!((h + dh).max(0.0))); + } + crate::cmd::arrange::resize_cmd(s, &Value::Object(q))?; + } + ok() +} + +pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { + let ids = req_targets("object.toggle", s, p)?; + let doc = s.doc_mut()?; + let explicit = boolean(p, what); + // Figma: if any is visible, hide all (and vice versa). + let value = explicit.unwrap_or_else(|| match what { + "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), + _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), + }); + for i in &ids { + let n = doc.get_mut(*i)?; + if what == "visible" { + n.visible = value; + } else { + n.locked = value; + } + } + if what == "locked" && value { + s.set_selection(vec![]); + } + Ok(json!({what: value})) +} + +pub(super) fn hide_others(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc_mut()?; + let keep: BTreeSet = sel + .iter() + .flat_map(|i| { + let mut v = doc.ancestors(*i); + v.push(*i); + v + }) + .collect(); + let tops: Vec = doc.get(page)?.children.clone(); + for t in tops { + if !keep.contains(&t) { + doc.get_mut(t)?.visible = false; + } + } + ok() +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 13eb21d62..fed4bff10 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,6 +11,36 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{DocState, EngineError, Result, Session, bad, spec}; +mod export; +mod load; +mod open; +mod save; +mod version; + +pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS; +#[cfg(test)] +use export::add_member; +pub(super) use export::export_setting; +pub use export::{ + ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, + frames_pdf, jobs_for, safe_file_name, +}; +use export::{export, export_frames_pdf, export_layers, pdf_bytes}; +#[cfg(test)] +pub(crate) use load::{installed_family, sketch_line}; +pub use load::{load, load_bytes, load_path}; +pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; +pub use open::MAX_RECENT; +#[cfg(test)] +use open::append_pages; +use open::{import, open, open_recent}; +pub(crate) use save::save_ends_preview; +pub use save::{FileSnapshot, file_bytes, thumbnail}; +use save::{color_profile, mark_saved, save, save_as}; +pub use version::MAX_AUTOSAVES; +pub(crate) use version::{add_version, keep_in_history, read_version, unique_id}; +use version::{autosave, restore_version, save_version, version_index, version_json, version_list}; + /// A new file of `kind` in a tab of its own. fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); @@ -295,987 +325,6 @@ pub fn specs() -> Vec { ] } -fn version_json(index: usize, v: &Version) -> Value { - let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); - if v.auto { - j["auto"] = json!(true); - } - j -} - -/// Autosaves kept per file; older ones make room for new ones. -pub const MAX_AUTOSAVES: usize = 20; - -/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. -fn autosave(s: &mut Session, _: &Value) -> Result { - let auto_save = s.prefs.auto_save; - let ds = s.ds_mut()?; - // A file that saves itself is rarely unsaved: edits since it was opened count then. - let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); - if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { - return Ok(json!({"saved": false})); - } - Ok(match add_version(ds, AUTOSAVE, "", true) { - Some(v) => json!({"saved": true, "version": v}), - None => json!({"saved": false}), - }) -} - -/// What checkpoints are called. -const AUTOSAVE: &str = "Autosave"; - -/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest -/// autosave makes room when the autosaves or the history are full; versions the user named are -/// never dropped, so a history full of them takes nothing (None). -pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { - let autos = ds.versions.iter().filter(|v| v.auto).count(); - if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) - && let Some(oldest) = ds.versions.iter().position(|v| v.auto) - { - ds.versions_mut().remove(oldest); - } - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return None; - } - let mut v = snapshot_version(ds, name, description); - v.auto = auto; - let out = version_json(ds.versions.len(), &v); - ds.versions_mut().push(v); - ds.mark_checkpoint(); - Some(out) -} - -/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless -/// it is what the last version saved or restored holds. -pub(crate) fn keep_in_history(ds: &mut DocState) { - if ds.branch_of.is_none() && ds.changed_since_checkpoint() { - add_version(ds, AUTOSAVE, "", true); - } -} - -/// A version of `ds`'s document as it is now, opening on the page shown. -fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { - let mut snap = ds.doc.clone(); - if snap.current_page != Some(ds.page) { - Arc::make_mut(&mut snap).current_page = Some(ds.page); - } - Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, super::comment::now_secs(), snap) -} - -fn version_list(ds: &DocState) -> Value { - let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); - Value::Array( - ds.versions - .iter() - .enumerate() - .map(|(i, v)| { - let mut j = version_json(i, v); - if previewing == Some(v.id.as_str()) { - j["previewing"] = json!(true); - } - j - }) - .collect(), - ) -} - -/// The version named by `id`, else by `index` (validated: never negative, never past the end). -fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { - if let Some(id) = string(p, "id") { - return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); - } - if get(p, "index").is_none() { - return Err(bad(cmd, "pass the version's `id` or `index`")); - } - let i = req_index(cmd, p, "index")?; - if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } -} - -pub(crate) fn read_version(v: &Version) -> Result> { - v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) -} - -/// A new version or branch id: the time in milliseconds (hex), not `taken`. -pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { - let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); - loop { - let id = format!("{n:x}"); - if !taken(&id) { - return id; - } - n = n.wrapping_add(1); - } -} - -/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. -fn save_version(s: &mut Session, p: &Value) -> Result { - let ds = s.ds_mut()?; - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return Err(EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - )); - } - let name = - string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); - let description = string(p, "description").unwrap_or_default(); - // The version opens on the page shown when it was saved. - add_version(ds, &name, description.trim(), false).ok_or_else(|| { - EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - ) - }) -} - -fn restore_version(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "version.restore"; - // Refused before anything goes into the history. - if let Some(why) = s.live_read_only(s.ds()?.uid) { - return Err(EngineError::Disabled(CMD.into(), why.into())); - } - let ds = s.ds_mut()?; - let i = version_index(CMD, ds, p)?; - let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; - let (id, mut doc) = (v.id.clone(), read_version(v)?); - // Restoring never loses work: the document as it is goes into the history first. - keep_in_history(ds); - let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); - let d = Arc::make_mut(&mut doc); - d.name = ds.doc.name.clone(); - // What the file published belongs to the file, not to the version. - d.published = ds.doc.published.clone(); - // Comments are a conversation about the file, kept apart from its design history: they stay, - // and those on layers the version doesn't have stay where they were. - d.comments = ds.doc.comments.clone(); - super::comment::follow_anchors(d, &ds.doc); - // The snapshot is already consistent: nothing to re-normalise. - d.take_dirty(); - // Stay on the current page when the version has it. - ds.page = Some(ds.page) - .filter(|pg| d.pages.contains(pg)) - .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) - .or_else(|| d.first_page()) - .unwrap_or(ds.page); - ds.doc = doc; - ds.selection.clear(); - ds.text_edit = None; - ds.vector_edit = None; - // The restored design is in the history already: restoring it again keeps nothing new. - ds.mark_checkpoint(); - Ok(out) -} - -/// The recovery copy at `path` as a document with unsaved changes and no path. -pub(crate) fn recovered(path: &str) -> Result { - let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; - Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn read(path: &str) -> Result> { - crate::file_access::read(path) -} - -#[cfg(target_arch = "wasm32")] -fn read(path: &str) -> Result> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -/// The bytes of file `path`, refused when it's larger than `max`. -pub(crate) fn read_capped(path: &str, max: u64) -> Result> { - crate::file_access::read_capped(path, max) -} - -/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one -/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never -/// truncates `path`, and no file or symlink at a staging name is ever written through. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - crate::file_access::write(path, bytes) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -fn ext(path: &str) -> String { - std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() -} - -fn file_stem(path: &str) -> String { - std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() -} - -/// The counts the import report shows for a freshly loaded document. -fn import_summary(doc: &Document) -> Value { - json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) -} - -/// Load a document from any supported file (REST API file JSON images come from an `images` -/// folder next to the file). -pub fn load(path: &str) -> Result<(Document, Vec)> { - load_path(path, None).map(|(l, _)| (l.doc, l.issues)) -} - -pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { - load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) -} - -/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST -/// import reads its variables from `variables`, else from a variables JSON next to the file. -pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { - let bytes = read(path)?; - let sibling = if variables.is_none() { sibling_variables(path) } else { None }; - load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) -} - -/// Load from bytes. Returns the document with its notes (repairs of a native file, or import -/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` -/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. -fn load_with( - bytes: &[u8], - name_hint: &str, - variables: Option<&Value>, - images: figcraft_format::rest::ImageResolver, -) -> Result<(figcraft_format::Loaded, bool)> { - match ext(name_hint).as_str() { - "svg" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) - .map_err(|e| EngineError::Other(e.to_string()))? - .notes; - Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) - } - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; - let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; - doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - Ok((doc.into(), false)) - } - _ => { - if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); - } - let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - // Older files saved an untouched default title even after the user chose a filename. - if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { - loaded.doc.name = file_stem(name_hint); - } - Ok((loaded, false)) - } - } -} - -/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. -fn load_sketch(bytes: &[u8], name: &str) -> Option> { - if !figcraft_format::sketch::is_sketch(bytes) { - return None; - } - // The import and what follows read the installed fonts: the system's must be known first (the - // desktop app scans them in the background; this waits for that scan). - figcraft_text::fontdb::scan_system_fonts(); - let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); - let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - match_font_families(&mut doc); - platform_tracking(&mut doc); - sketch_line_heights(&mut doc); - link_instances(&mut doc); - reflow_overridden_text(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Instances of a stack sized apart from their main are laid out at their size, and texts in -/// instances whose string an override changed are measured again with the auto layout around them -/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout -/// Sketch saved. -fn reflow_overridden_text(doc: &mut Document) { - let changed: Vec = doc - .nodes() - .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { - (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, - _ => false, - }) - .map(|n| n.id) - .collect(); - let m = crate::measure::TextMeasure; - // Instances of a stack at another size than their main: the stack at their size, innermost first. - let mut resized: Vec<(usize, NodeId)> = doc - .nodes() - .filter(|n| n.layout.is_some()) - .filter(|n| match &n.kind { - NodeKind::Instance { component, .. } => { - doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) - } - _ => false, - }) - .map(|n| (doc.ancestors(n.id).len(), n.id)) - .collect(); - resized.sort_by_key(|r| std::cmp::Reverse(r.0)); - for (_, id) in resized { - figcraft_layout::layout_frame(doc, id, &m); - } - for id in changed { - if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { - figcraft_layout::set_size(doc, id, w, h); - } - figcraft_layout::layout_up(doc, id, &m); - } -} - -/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from -/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and -/// case. -fn match_font_families(doc: &mut Document) { - let installed = figcraft_text::fontdb::family_names(); - let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - let mut fix = |style: &mut figcraft_doc::TextStyle| { - let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); - if let Some(f) = found { - style.font_family = f.clone(); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - fix(&mut td.style); - for r in &mut td.runs { - fix(&mut r.style); - } - } - } - // Shared text styles too, so they stay like the layers linked to them. - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - fix(style); - } - } -} - -/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine -/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so -/// lines measure and break as they did there. A named style's optical size follows the text size -/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. -fn platform_tracking(doc: &mut Document) { - let mut cache: std::collections::BTreeMap<(String, String), Option>> = - std::collections::BTreeMap::new(); - let mut face = |style: &figcraft_doc::TextStyle| { - cache - .entry((style.font_family.clone(), style.font_style.clone())) - .or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - }) - .clone() - }; - let mut track = |style: &mut figcraft_doc::TextStyle| { - let Some(f) = face(style) else { return }; - let t = f.tracking(style.font_size); - if t != 0.0 && t.is_finite() { - let px = style.letter_spacing.resolve(style.font_size); - style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); - } - if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { - style.variations.entry("opsz".into()).or_insert(style.font_size); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - track(&mut td.style); - for r in &mut td.runs { - track(&mut r.style); - } - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - track(style); - } - } -} - -/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their -/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. -const TALL_LINE_FAMILIES: [&str; 11] = - ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; - -/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its -/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch -/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth -/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres -/// the font's own ascent and descent in the line. `(0, 0)` without metrics. -pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { - let k = size / upem; - let (a, d) = (ascent * k, descent * k); - if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { - return (0.0, 0.0); - } - let natural = a.round() + d.round(); - let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; - let sketch_baseline = line - d.round(); - let figcraft_baseline = (line - (a + d)) / 2.0 + a; - (line, sketch_baseline - figcraft_baseline) -} - -/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, -/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a -/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text -/// styles take the line too. -fn sketch_line_heights(doc: &mut Document) { - // Per family and style: the face's ascent, descent and units per em. - type Metrics = Option<(f64, f64, f64)>; - let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); - let mut set = |style: &mut TextStyle| -> f64 { - if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { - return 0.0; - } - let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - .map(|f| (f.ascent, f.descent, f.upem)) - }); - let Some((a, d, upem)) = m else { return 0.0 }; - let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); - if line > 0.0 { - style.line_height = LineHeight::Percent(line / style.font_size * 100.0); - } - down - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - // An auto layout places its texts itself (its next pass would take a move back). - let laid_out = - doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let Some(n) = doc.node_mut(id) else { continue }; - let NodeKind::Text(td) = &mut n.kind else { continue }; - let down = set(&mut td.style); - for r in &mut td.runs { - set(&mut r.style); - } - // Down in the layer's own frame (a rotated text moves along its own axis). - if !laid_out && down.is_finite() && down != 0.0 { - n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - set(style); - } - } -} - -/// The installed family `family` means when it isn't installed under that exact name: the one -/// whose name is the same without spaces, ignoring case. -pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { - let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); - if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { - return None; - } - let want = key(family); - installed.iter().find(|f| key(f) == want).cloned() -} - -/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. -pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { - let r = figcraft_format::fig::try_import(bytes, name)?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - link_instances(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Link imported instances to their mains (layer sources, overrides found by comparing values), -/// innermost first so outer instances settle last. -fn link_instances(doc: &mut Document) { - super::styles::unfold_imported_bound_alpha(doc); - crate::style_vars::after_load(doc); - super::styles::apply_paint_bindings_on_load(doc); - let mut insts: Vec<(usize, NodeId)> = - doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); - insts.sort_by_key(|a| std::cmp::Reverse(a.0)); - for (_, i) in insts { - crate::components::rebase_instance(doc, i); - } -} - -/// Largest image file read for a REST API file JSON import. -#[cfg(not(target_arch = "wasm32"))] -const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; - -/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to -/// the file. -#[cfg(not(target_arch = "wasm32"))] -fn rest_images(path: &str) -> impl Fn(&str) -> Option> { - let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); - move |r: &str| { - // Image refs are content hashes; anything else never becomes a path. - if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { - return None; - } - let dir = dir.as_ref()?; - ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] - .iter() - .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) - } -} - -#[cfg(target_arch = "wasm32")] -fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { - figcraft_format::rest::no_images -} - -/// Largest variables JSON read from disk. -#[cfg(not(target_arch = "wasm32"))] -const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; - -/// A local variables JSON next to a REST API file JSON: `.variables.json`, else -/// `variables.json`. -#[cfg(not(target_arch = "wasm32"))] -fn sibling_variables(path: &str) -> Option { - if ext(path) != "json" { - return None; - } - let file = std::path::Path::new(path); - let dir = file.parent()?; - [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { - let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; - figcraft_format::rest::is_variables_json(&v).then_some(v) - }) -} - -#[cfg(target_arch = "wasm32")] -fn sibling_variables(_path: &str) -> Option { - None -} - -/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. -pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { - let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); - let v = match get(p, "variables") { - None | Some(Value::Null) => return Ok(None), - Some(v @ Value::Object(_)) => v.clone(), - Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, - Some(Value::String(path)) => parse(&read(path)?)?, - Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), - }; - if !figcraft_format::rest::is_variables_json(&v) { - return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); - } - Ok(Some(v)) -} - -/// How many files File ▸ Open recent keeps. -pub const MAX_RECENT: usize = 20; - -/// Put `path` at the top of the recent files. -fn remember(s: &mut Session, path: &str) { - let r = &mut s.prefs.recent_files; - r.retain(|p| p != path); - r.insert(0, path.to_string()); - r.truncate(MAX_RECENT); -} - -fn open_recent(s: &mut Session, p: &Value) -> Result { - use crate::file_access::{Access, allow, allowed}; - const CMD: &str = "file.openRecent"; - // Confined to automation roots, the recent files outside them are never listed, opened, removed - // or named: `index` counts the files listed. - let listed = |r: &String| allowed(r, Access::Read); - if p.get("clear").and_then(Value::as_bool) == Some(true) { - s.prefs.recent_files.retain(|r| !listed(r)); - } - if let Some(gone) = string(p, "remove") { - s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); - } - let path = match (string(p, "path"), num(p, "index")) { - (Some(path), _) => { - allow(&path, Access::Read)?; - Some(path) - } - (None, Some(i)) => { - Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) - } - _ => None, - }; - if let Some(path) = path { - if !crate::file_access::exists(&path) { - s.prefs.recent_files.retain(|r| *r != path); - return Err(bad(CMD, format!("{path} no longer exists"))); - } - return open(s, &json!({"path": path})); - } - let files: Vec = s - .prefs - .recent_files - .iter() - .filter(|r| listed(r)) - .map(|r| { - let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); - json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) - }) - .collect(); - Ok(json!({"files": files})) -} - -fn open(s: &mut Session, p: &Value) -> Result { - let variables = variables_param("file.open", p)?; - if string(p, "path").is_none() - && let Some(d) = string(p, "data") - { - return open_data(s, &d, p, variables.as_ref()); - } - let path = req_string("file.open", p, "path")?; - // Already open: switch to it. - if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { - s.active = i; - remember(s, &path); - return Ok(json!({"index": i})); - } - // Stamped before reading: a change made during the read makes the next save refuse. - let on_disk = crate::library::stat(&path).ok(); - let (loaded, rest) = load_path(&path, variables.as_ref())?; - remember(s, &path); - let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); - let warnings = rest || ext(&path) == "svg"; - // A native file that lost something (a damaged image, say): the app says so, not only the result. - if native && !loaded.issues.is_empty() { - s.message = Some(match loaded.issues.as_slice() { - [one] => format!("{}: {one}", file_stem(&path)), - more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), - }); - } - let summary = import_summary(&loaded.doc); - let images_left_out = if native { loaded.images_past_budget } else { 0 }; - let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, images_left_out); - ds.on_disk = on_disk.filter(|_| native); - s.docs.push(ds); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells -/// the format. It opens as a new document with no path, so the first save asks where to put it. -fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { - let name = req_string("file.open", p, "name")?; - let data = data.split_once(',').map_or(data, |(_, b)| b); - let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; - let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; - let warnings = rest || ext(&name) == "svg"; - let summary = import_summary(&loaded.doc); - s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// Thumbnail PNG of the first page's content (for file browsers / the home screen). -pub fn thumbnail(doc: &Document) -> Option> { - let page = doc.first_page()?; - let b = doc.page_content_bounds(page)?; - let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { - origin: (b.x0, b.y0), - scale, - width: (b.width() * scale).ceil().max(1.0) as u32, - height: (b.height() * scale).ceil().max(1.0) as u32, - }; - let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; - figcraft_render::encode_png(&pix).ok() -} - -/// The `.figcraft` file for a document: content, thumbnail and version history. -pub fn file_bytes(ds: &DocState) -> Result> { - FileSnapshot::of(ds).encode() -} - -/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the -/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then -/// [`save`](Self::save) the bytes back on the session's thread. -pub struct FileSnapshot { - uid: u64, - path: Option, - revision: u64, - doc: Arc, - /// The document as last saved when the snapshot was taken: another save since makes it stale. - saved: Arc, - page: NodeId, - versions: Vec, - branches: Vec, -} - -impl FileSnapshot { - pub fn of(ds: &DocState) -> Self { - Self { - uid: ds.uid, - path: ds.path.clone(), - revision: ds.revision, - doc: ds.doc.clone(), - saved: ds.saved_doc.clone(), - page: ds.page, - versions: ds.versions.clone(), - branches: ds.branches.clone(), - } - } - - /// The file's bytes: content, thumbnail and version history. - pub fn encode(&self) -> Result> { - let mut doc = (*self.doc).clone(); - doc.current_page = Some(self.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) - .map_err(|e| EngineError::Other(e.to_string())) - } - - /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved - /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document - /// was closed, saved, reverted or given another path since: the bytes are out of date. - pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { - let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { - return Ok(false); - }; - let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; - refuse_dropping_images(ds, path)?; - refuse_overwriting_changes(ds, path)?; - write(path, bytes)?; - ds.note_on_disk(); - // The history or branches may have changed since: then they still count as unsaved. - if ds.revision == self.revision { - ds.versions_changed = false; - } - ds.saved_doc = self.doc; - ds.revision += 1; - branch_tabs_saved(s, self.uid); - Ok(true) - } -} - -fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { - match ds.images_lost_saving_to(path) { - Some(count) => Err(EngineError::Other(format!( - "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", - file_stem(path) - ))), - None => Ok(()), - } -} - -/// Saving over the tab's own file would lose what another app wrote to it since (#1330). -fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { - match (&ds.on_disk, crate::library::stat(path)) { - (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( - "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", - file_stem(path) - ))), - _ => Ok(()), - } -} - -/// The branch tabs of the file in tab `uid` were saved with it. -fn branch_tabs_saved(s: &mut Session, uid: u64) { - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } -} - -fn color_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "file.colorProfile"; - let Some(name) = string(p, "profile") else { - s.request_ui("dialog.colorProfile", json!({})); - return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); - }; - let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; - let convert = boolean(p, "convert").unwrap_or(true); - let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); - Ok(json!({"profile": profile.id(), "changed": changed})) -} - -/// Native saves act on the current document; a `.fig` copy exports the version shown, and a -/// pending Save As choice must leave it shown until its destination is known. -pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { - let path = - string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) -} - -/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). -fn wants_data(p: &Value) -> bool { - p.get("data").and_then(Value::as_bool) == Some(true) -} - -/// Save the file of the active tab (the main file for a branch tab, with its branches), or export -/// the active document as an experimental `.fig` copy without changing its native save state. -/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). -fn save_to(s: &mut Session, path: Option) -> Result { - if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { - return export_fig(s, &json!({"path": path})); - } - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - // A download (no path) writes over no file. - if let Some(path) = path.as_deref() { - refuse_dropping_images(ds, path)?; - } - let mut snapshot = FileSnapshot::of(ds); - let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if let Some(path) = path.as_deref() - && follows_path - && ds.path.as_deref() != Some(path) - { - Arc::make_mut(&mut snapshot.doc).name = file_stem(path); - } - let bytes = snapshot.encode()?; - let Some(path) = path else { - // The download can still be refused: the caller marks the file saved once it was delivered. - let name = format!("{}.figcraft", ds.doc.name); - return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); - }; - write(&path, &bytes)?; - ds.doc = snapshot.doc; - ds.path = Some(path.clone()); - ds.note_on_disk(); - mark_saved(s, &json!({}))?; - remember(s, &path); - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -/// The file of the active tab (with its branch tabs) now matches what was written or delivered. -fn mark_saved(s: &mut Session, _: &Value) -> Result { - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); - Ok(json!({"saved": true})) -} - -fn save(s: &mut Session, p: &Value) -> Result { - if let Some(path) = string(p, "path") { - return save_to(s, Some(path)); - } - let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; - match ds.path.clone() { - // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. - Some(path) if ext(&path) == "fig" => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) - } - Some(path) => { - if p.get("overwrite").and_then(Value::as_bool) != Some(true) { - refuse_overwriting_changes(ds, &path)?; - } - save_to(s, Some(path)) - } - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) - } - } -} - -fn save_as(s: &mut Session, p: &Value) -> Result { - match string(p, "path") { - Some(mut path) => { - if ext(&path).is_empty() { - path.push_str(".figcraft"); - } - save_to(s, Some(path)) - } - None if wants_data(p) => save_to(s, None), - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) - } - } -} - -/// Encode `ids` (or the page) in `format` at `scale`. -pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { - export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) -} - -/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, -/// so they take no room next to visible ones (a hidden-only export keeps its own area). -pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { - let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); - ids.iter() - .filter_map(|i| { - let node = doc.node(*i); - let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { - doc.world_bounds(*i) - } else if any_visible && node.is_some_and(|n| !n.visible) { - None - } else { - figcraft_render::export_bounds(doc, *i) - } - }) - .reduce(|a, b| a.union(b)) -} - -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) -/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing -/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a -/// slice anywhere else draws every top-level layer of its page. -pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { - return super::edit::z_sorted(doc, ids); - }; - if !setting.contents_only { - return page.children.clone(); - } - let mut own = Vec::with_capacity(ids.len()); - for &i in ids { - if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { - own.push(i); - continue; - } - match slice_container(doc, i) { - Some(c) => own.push(c), - None => return page.children.clone(), - } - } - own.sort_unstable(); - own.dedup(); - super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) -} - -/// The frame or group a slice sits in: None on the page or in a section. -fn slice_container(doc: &Document, slice: NodeId) -> Option { - let parent = doc.node(slice)?.parent?; - doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) -} - -/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see -/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. -pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { - export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) -} - /// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only; /// empty for the other formats). pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec, Vec)> { @@ -1336,112 +385,6 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal } } -/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour -/// profile; the clipboard wants sRGB). -pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { - let area = export_area(doc, ids, &ExportSetting::default())?; - figcraft_render::render_area(doc, &super::edit::z_sorted(doc, ids), area, 2.0) -} - -fn export(s: &mut Session, p: &Value) -> Result { - let cmd = "file.export"; - let fig = match string(p, "format") { - Some(f) => f.eq_ignore_ascii_case("fig"), - None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), - }; - if fig { - return export_fig(s, p); - } - let doc = s.doc()?; - let mut ids = targets(s, p); - if ids.is_empty() { - ids = doc.get(s.page()?)?.children.clone(); - } - let mut setting = export_setting(cmd, ExportSetting::default(), p)?; - if string(p, "format").is_none() - && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) - { - setting.format = f; - } - let scale = match size_param(cmd, p)? { - Some(_) => export_scale(doc, &ids, &setting), - None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), - }; - let format = setting.format; - let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; - let mut out = match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) - } - None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), - }; - // Android Vector Drawables say what they changed or left out (an empty list when nothing). - if format == ExportFormat::VectorDrawable { - out["warnings"] = json!(warnings); - } - Ok(out) -} - -/// The whole document as a `.fig` file (experimental: only round trips through our own importer -/// are verified), with the warnings about what was left out. -fn export_fig(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let (bytes, warnings) = fig_bytes(doc)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) - } - None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), - } -} - -/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. -pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { - figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) -} - -/// The scale that gives `ids` (their export area together) `setting`'s export size. -pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { - // Size-based exports ask for bounds before encoding. Let encoding report the depth error - // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. - if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { - return 1.0; - } - export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) -} - -/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. -pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { - if let Some(size) = size_param(cmd, p)? { - e.size = size; - } - if let Some(s) = string(p, "suffix") { - e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); - } - if let Some(f) = string(p, "format") { - e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; - } - if let Some(c) = vd_compat_param(cmd, p)? { - e.vd_compat = c; - } - e.print = print_param(cmd, p, e.print)?; - for (key, slot) in [ - ("contentsOnly", &mut e.contents_only), - ("includeBoundingBox", &mut e.include_bounding_box), - ("svgOutlineText", &mut e.svg_outline_text), - ("svgIdAttribute", &mut e.svg_id_attribute), - ("svgSimplifyStroke", &mut e.svg_simplify_stroke), - ] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(e) -} - /// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). fn vd_compat_param(cmd: &str, p: &Value) -> Result> { match get(p, "vdCompat") { @@ -1450,345 +393,6 @@ fn vd_compat_param(cmd: &str, p: &Value) -> Result> { } } -/// Whether `p` sets any PDF print option. -fn has_print_param(p: &Value) -> bool { - ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) -} - -/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or -/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. -pub(super) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { - match get(p, "bleed") { - None | Some(Value::Null) => {} - Some(v) => { - let pt = match v { - Value::String(s) => PrintSettings::parse_length(s), - v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), - }; - base.bleed = pt - .filter(|b| *b <= PrintSettings::MAX_BLEED) - .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; - } - } - for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(base) -} - -/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. -fn size_param(cmd: &str, p: &Value) -> Result> { - let size = match get(p, "size") { - None | Some(Value::Null) => return Ok(None), - Some(Value::String(s)) => ExportSize::parse(s), - Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), - Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), - }; - size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) -} - -/// Most files one `file.exportLayers` writes. -const MAX_EXPORT_FILES: usize = 1000; -/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy -/// are all in memory at once (in the browser too). -const MAX_ZIP_BYTES: usize = 256 << 20; - -/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. -fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { - let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); - if total > limit { - let mb = limit >> 20; - return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); - } - members.push((name, bytes)); - Ok(()) -} - -/// One file of a layer export: the layer, its setting and the file name. -pub struct ExportJob { - pub id: NodeId, - pub setting: ExportSetting, - pub file: String, -} - -/// The files exporting `ids` with their export settings makes, with unique names (repeats get -/// " 2", " 3"…). Layers without settings make none. -pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { - jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) -} - -/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). -pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { - let mut used = std::collections::BTreeSet::new(); - let mut out = Vec::new(); - for (id, settings) in items { - let Some(n) = doc.node(id) else { continue }; - let base = export_path(&n.name); - for e in settings { - if out.len() >= MAX_EXPORT_FILES { - return out; - } - let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); - let ext = e.format.ext(); - let file = - (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); - if let Some(file) = file { - out.push(ExportJob { id, setting: e, file }); - } - } - } - out -} - -/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and -/// `..` segments are dropped, so it stays inside the export folder. -fn export_path(name: &str) -> String { - let parts: Vec = - name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); - if parts.is_empty() { "Untitled".into() } else { parts.join("/") } -} - -/// Characters no file name may contain (path separators and ones file systems reject). -pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; - -/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and -/// control characters become `_`); everything else is kept, up to the first 200 characters. -/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before -/// the number and extension it adds). -pub fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() -} - -/// Layers on `page` that have export settings (document order). -pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { - doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() -} - -fn export_layers(s: &mut Session, p: &Value) -> Result { - let cmd = "file.exportLayers"; - let doc = s.doc()?; - let assets = boolean(p, "assets").unwrap_or(false); - let mut ids = targets(s, p); - if ids.is_empty() { - ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; - } - let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; - if jobs.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); - } - let dir = string(p, "dir"); - // The browser saves one download at a time: several files go as one zip (#969). - let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); - let mut members = Vec::new(); - // Print options passed here stand in for every PDF setting's own. - let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; - // And a compatibility mode for every Android Vector Drawable. - let compat = vd_compat_param(cmd, p)?; - let mut files = Vec::with_capacity(jobs.len()); - for job in &jobs { - let setting = - ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; - let scale = export_scale(doc, &[job.id], &setting); - let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; - let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); - if setting.format == ExportFormat::VectorDrawable { - f["warnings"] = json!(warnings); - } - match &dir { - Some(d) => { - let path = std::path::Path::new(d).join(&job.file); - // A layer name with slashes exports into folders. - if let Some(parent) = path.parent() { - let _ = crate::file_access::create_dir_all(parent); - } - let path = path.to_string_lossy().to_string(); - write(&path, &bytes)?; - f["path"] = json!(path); - } - None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, - None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), - } - files.push(f); - } - let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); - if zip { - let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; - r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); - } - Ok(r) -} - -/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` -/// sizes a single page to that page area instead of the layers' bounds. -fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { - let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; - figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) -} - -/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. -pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { - let mut frames = doc.top_frames(page); - frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); - if frames.is_empty() { - return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); - } - let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); - Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) -} - -/// Every top-level frame of a page (reading order), one PDF page each. -fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let page = match id(p, "page") { - Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, - Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), - None => s.page()?, - }; - let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; - let (bytes, pages) = frames_pdf(doc, page, &print)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) - } - None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), - } -} - -fn import(s: &mut Session, p: &Value) -> Result { - let path = string(p, "path"); - let variables = variables_param("file.import", p)?; - let (bytes, name) = if let Some(path) = &path { - (read(path)?, path.clone()) - } else if let Some(d) = string(p, "data") { - let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); - ( - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, - string(p, "name").unwrap_or_else(|| "import.png".into()), - ) - } else { - return Err(bad("file.import", "pass `path` or `data`")); - }; - let page = s.page()?; - let (x, y) = (num(p, "x"), num(p, "y")); - let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); - match ext(&name).as_str() { - "svg" => { - let doc = s.doc_mut()?; - let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); - let imported = - figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; - let id = imported.frame; - // Centred on the point (a paste lands in the middle of the view). - if boolean(p, "center").unwrap_or(false) - && let Some(n) = doc.node_mut(id) - { - n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); - } - s.set_selection(vec![id]); - let mut out = json!({"id": id}); - if !imported.notes.is_empty() { - out["warnings"] = json!(imported.notes); - } - Ok(out) - } - "figcraft" | "json" | "fig" | "sketch" => { - let resolver = path.as_deref().map(rest_images); - let images: figcraft_format::rest::ImageResolver = match &resolver { - Some(r) => r, - None => &figcraft_format::rest::no_images, - }; - let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; - let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; - let summary = import_summary(&other.doc); - let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages, "summary": summary}); - if rest { - out["warnings"] = json!(other.issues); - } - Ok(out) - } - _ => { - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - super::create::place_image(s, &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)})) - } - } -} - -/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to -/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers -/// their source and interactions and flows their targets wherever those are. Styles, variables and -/// collections whose id the document already has are kept as they are. -fn append_pages(doc: &mut Document, other: &Document) -> Result> { - for (k, v) in &other.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - for st in &other.styles { - if !doc.styles.iter().any(|s| s.id == st.id) { - doc.styles.push(st.clone()); - } - } - let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); - doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); - let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); - doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); - let mut map = std::collections::BTreeMap::new(); - for pg in &other.pages { - for id in other.subtree(*pg).into_iter().skip(1) { - map.insert(id, doc.alloc_id()); - } - } - // Library copies a .fig import keeps (cached definitions) have no library record to follow: - // the layers of its store come along into the store of `doc`. Recorded copies are left to - // `merge_store`, which shares them with copies `doc` already has. - let cached: Vec = other - .library_store() - .and_then(|s| other.node(s)) - .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) - .unwrap_or_default(); - for root in &cached { - for id in other.subtree(*root) { - map.insert(id, doc.alloc_id()); - } - } - let mut new_pages = Vec::new(); - for pg in &other.pages { - let Some(pn) = other.node(*pg) else { continue }; - let np = doc.add_page(&pn.name); - let mut kind = pn.kind.clone(); - if let NodeKind::Page { flows, .. } = &mut kind { - flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); - } - if let Some(n) = doc.node_mut(np) { - n.kind = kind; - } - for c in &pn.children { - let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *c, np, None)?; - } - new_pages.push(np); - } - if !cached.is_empty() { - let store = doc.ensure_library_store(); - for root in &cached { - let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *root, store, None)?; - } - } - for new in map.values() { - let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); - if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { - n.source = Some(src); - } - } - crate::library::merge_store(doc, other, &map)?; - Ok(new_pages) -} - fn close(s: &mut Session, p: &Value) -> Result { let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; @@ -1807,26 +411,7 @@ fn close(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tab_order_tests { - use super::*; - - #[test] - fn moving_and_closing_inactive_tabs_preserves_the_active_document() { - let mut session = Session::new(); - for name in ["Two", "Three", "Four"] { - session.execute("file.new", &json!({"name": name})).unwrap(); - } - session.execute("doc.switch", &json!({"index": 2})).unwrap(); - let active_uid = session.docs[session.active].uid; - let moved_uid = session.docs[0].uid; - session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); - assert_eq!(session.docs[2].uid, moved_uid); - assert_eq!(session.docs[session.active].uid, active_uid); - session.execute("file.close", &json!({"index": 0})).unwrap(); - assert_eq!(session.docs[session.active].uid, active_uid); - assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); - } -} +mod tab_order_tests; /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { @@ -1839,331 +424,10 @@ fn cycle_doc(s: &mut Session, dir: i64) -> Result { } #[cfg(test)] -mod rest_import_tests { - use super::*; - - /// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text - /// is overridden, and an image fill. - fn kit() -> Value { - let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); - let label = |id: &str, text: &str| { - json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, - "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) - }; - json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ - {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ - {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, - {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, - {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, - "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} - ]} - ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) - } - - fn instance(doc: &Document) -> &Node { - doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() - } - - /// What a .fig import with a cached library component leaves: the main in the hidden store, a - /// visible instance of it and a layer linked to the main's layer. - fn cached_import() -> Document { - let mut doc = Document::new("Cached"); - let page = doc.first_page().unwrap(); - let store = doc.ensure_library_store(); - let main = doc - .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) - .unwrap(); - let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); - let inst = - doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); - let mut copy = doc.node(body).unwrap().clone(); - copy.id = doc.alloc_id(); - copy.parent = None; - copy.source = Some(body); - let copy_id = copy.id; - doc.insert(copy, inst, None).unwrap(); - assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); - doc - } - - /// Pasting layers that use a cached main into another document leaves the main behind, as it - /// does for other mains that are not published: no link points at a layer of the target. - #[test] - fn pasting_instances_of_cached_components_leaves_no_dangling_links() { - let src = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; - let ids = src.subtree(inst); - let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); - let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); - crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); - let pasted = doc.node(map[&inst]).unwrap(); - let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; - assert!(!doc.contains(component), "the main stays behind: {component:?}"); - assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); - } - - #[test] - fn appending_pages_with_cached_library_components_brings_the_components() { - let other = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - // Ids of the target that would collide with the other document's store. - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - append_pages(&mut doc, &other).unwrap(); - assert!(doc.validate().is_empty(), "{:?}", doc.validate()); - assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); - let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(doc.node(component).unwrap().name, "Cached main"); - assert!(doc.is_remote(component), "the main comes along into the store"); - let layer = doc.node(inst.children[0]).unwrap(); - assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); - } - - #[test] - fn rest_json_loads_with_linked_instances() { - let bytes = serde_json::to_vec(&kit()).unwrap(); - let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); - assert_eq!(doc.name, "Kit"); - assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); - let inst = instance(&doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(inst.source, Some(component)); - let main_label = doc.node(component).unwrap().children[0]; - let label = doc.node(inst.children[0]).unwrap(); - assert_eq!(label.source, Some(main_label)); - assert!(label.overrides.contains("characters"), "{:?}", label.overrides); - assert!(crate::components::mirror_issues(&doc).is_empty()); - assert!(thumbnail(&doc).is_some()); - } - - #[test] - fn open_takes_data_and_a_name_as_a_new_unsaved_document() { - let mut saved = Session::new(); - saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let bytes = file_bytes(saved.ds().unwrap()).unwrap(); - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - let mut s = Session::new(); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); - let open = s.docs.len(); - assert_eq!(r["index"], open - 1, "a document of its own, made active"); - assert_eq!(s.ds().unwrap().path, None, "no path to save over"); - assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); - assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); - assert_eq!(s.docs.len(), open, "a refused open adds nothing"); - } - - #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { - // The browser has no paths: Save as returns the file for a download (#969). - let mut s = Session::new(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); - assert_eq!(r["name"], "Untitled.figcraft"); - assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); - s.execute("file.markSaved", &json!({})).unwrap(); - assert!(!s.ds().unwrap().is_dirty()); - assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - let mut t = Session::new(); - t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); - assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); - } - - #[test] - fn open_and_import_rest_json() { - let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); - std::fs::create_dir_all(dir.join("images")).unwrap(); - let png = { - let mut d = Document::new("x"); - let p = d.first_page().unwrap(); - let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); - export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() - }; - std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); - let path = dir.join("kit.json"); - std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); - let path = path.to_string_lossy().to_string(); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path})).unwrap(); - assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); - assert!(r.get("repairs").is_none()); - assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); - let doc = s.doc().unwrap(); - let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); - let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; - assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); - - // Import into another document: pages are appended and references follow the new ids. - let mut s = Session::new(); - let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); - let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); - assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); - assert!(r["warnings"].is_array(), "{r}"); - let doc = s.doc().unwrap(); - assert_eq!(doc.pages.len(), 2); - let page = doc.node(doc.pages[1]).unwrap(); - assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); - let inst = instance(doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); - let label = doc.node(inst.children[0]).unwrap(); - assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); - assert!(crate::components::mirror_issues(doc).is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod rest_import_tests; #[cfg(test)] -mod staging_write_tests { - use super::*; - - fn dir(name: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). - #[cfg(unix)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::unix::fs::symlink(to, from).is_ok() - } - - #[cfg(windows)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::windows::fs::symlink_file(to, from).is_ok() - } - - #[test] - fn an_existing_staging_file_is_never_written_through() { - let d = dir("existing"); - let path = d.join("shot.svg"); - // The old deterministic staging name, held by an unrelated file: the write must pick its - // own staging name instead of truncating this one. - let old = d.join("shot.svg.tmp-figcraft"); - std::fs::write(&old, b"not yours").unwrap(); - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b""); - assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); - // Just the file and the untouched staging file: no new staging leftovers. - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_staging_symlink_is_never_followed() { - let d = dir("symlink"); - let path = d.join("shot.svg"); - let victim = d.join("victim.txt"); - std::fs::write(&victim, b"innocent").unwrap(); - let old = d.join("shot.svg.tmp-figcraft"); - if !symlink(&old, &victim) { - return; // Making a symlink needs privileges on some Windows setups. - } - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); - assert_eq!(std::fs::read(&path).unwrap(), b""); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn concurrent_writers_of_one_file_never_interleave() { - let d = dir("concurrent"); - let path = d.join("shot.svg"); - for round in 0..5u8 { - let mut handles = Vec::new(); - for w in 0u8..4 { - let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); - handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); - } - let mut wins = 0; - for h in handles { - // On Windows a rename over a file another writer just replaced can lose the race. - if h.join().unwrap().is_ok() { - wins += 1; - } - } - assert!(wins > 0, "round {round}: every writer lost"); - // Whatever landed is one writer's complete payload, never a mix. - let got = std::fs::read(&path).unwrap(); - assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); - assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); - } - // No staging file was left stuck (a failed writer removes its own). - let left = std::fs::read_dir(&d).unwrap().count(); - assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { - let d = dir("failure"); - // The destination is a directory: the rename fails, our staging file is removed and the - // directory is untouched. - let taken = d.join("over-a-directory"); - std::fs::create_dir_all(&taken).unwrap(); - assert!(write(&taken.to_string_lossy(), b"payload").is_err()); - assert!(taken.is_dir()); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); - // A missing parent directory fails without creating anything anywhere. - assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); - assert!(!d.join("no").exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - // A read-only directory refuses the staging file, and the old destination stays. - let ro = d.join("read-only"); - std::fs::create_dir_all(&ro).unwrap(); - let dest = ro.join("keep.svg"); - std::fs::write(&dest, b"keep").unwrap(); - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o500); - std::fs::set_permissions(&ro, perms).unwrap(); - // Running as root would defeat the read-only directory: skip instead of failing. - let probe = ro.join(".probe"); - if std::fs::write(&probe, b"").is_ok() { - let _ = std::fs::remove_file(&probe); - } else { - assert!(write(&dest.to_string_lossy(), b"payload").is_err()); - assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); - assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); - } - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o700); - std::fs::set_permissions(&ro, perms).unwrap(); - } - let _ = std::fs::remove_dir_all(&d); - } -} +mod staging_write_tests; #[cfg(test)] -mod zip_cap_tests { - use super::add_member; - - #[test] - fn zip_members_stop_at_the_limit_and_keep_what_fit() { - let mut members = Vec::new(); - add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); - add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); - let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); - assert!(err.contains("as one zip"), "{err}"); - assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); - } -} +mod zip_cap_tests; diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs new file mode 100644 index 000000000..005afe6ff --- /dev/null +++ b/crates/engine/src/cmd/file/export.rs @@ -0,0 +1,383 @@ +use super::load::{ext, write}; +use super::save::thumbnail; +use super::*; + +/// Encode `ids` (or the page) in `format` at `scale`. +pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { + export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) +} + +/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). +pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); + ids.iter() + .filter_map(|i| { + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } + }) + .reduce(|a, b| a.union(b)) +} + +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return crate::cmd::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); + } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) +} + +/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see +/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. +pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { + export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) +} + +/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour +/// profile; the clipboard wants sRGB). +pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { + let area = export_area(doc, ids, &ExportSetting::default())?; + figcraft_render::render_area(doc, &crate::cmd::edit::z_sorted(doc, ids), area, 2.0) +} + +pub(super) fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "file.export"; + let fig = match string(p, "format") { + Some(f) => f.eq_ignore_ascii_case("fig"), + None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), + }; + if fig { + return export_fig(s, p); + } + let doc = s.doc()?; + let mut ids = targets(s, p); + if ids.is_empty() { + ids = doc.get(s.page()?)?.children.clone(); + } + let mut setting = export_setting(cmd, ExportSetting::default(), p)?; + if string(p, "format").is_none() + && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) + { + setting.format = f; + } + let scale = match size_param(cmd, p)? { + Some(_) => export_scale(doc, &ids, &setting), + None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), + }; + let format = setting.format; + let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; + let mut out = match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) + } + None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), + }; + // Android Vector Drawables say what they changed or left out (an empty list when nothing). + if format == ExportFormat::VectorDrawable { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The whole document as a `.fig` file (experimental: only round trips through our own importer +/// are verified), with the warnings about what was left out. +pub(super) fn export_fig(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let (bytes, warnings) = fig_bytes(doc)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) + } + None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), + } +} + +/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. +pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { + figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) +} + +/// The scale that gives `ids` (their export area together) `setting`'s export size. +pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { + // Size-based exports ask for bounds before encoding. Let encoding report the depth error + // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. + if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { + return 1.0; + } + export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) +} + +/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. +pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { + if let Some(size) = size_param(cmd, p)? { + e.size = size; + } + if let Some(s) = string(p, "suffix") { + e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); + } + if let Some(f) = string(p, "format") { + e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; + } + if let Some(c) = vd_compat_param(cmd, p)? { + e.vd_compat = c; + } + e.print = print_param(cmd, p, e.print)?; + for (key, slot) in [ + ("contentsOnly", &mut e.contents_only), + ("includeBoundingBox", &mut e.include_bounding_box), + ("svgOutlineText", &mut e.svg_outline_text), + ("svgIdAttribute", &mut e.svg_id_attribute), + ("svgSimplifyStroke", &mut e.svg_simplify_stroke), + ] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(e) +} + +/// Whether `p` sets any PDF print option. +fn has_print_param(p: &Value) -> bool { + ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) +} + +/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or +/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. +pub(in crate::cmd) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { + match get(p, "bleed") { + None | Some(Value::Null) => {} + Some(v) => { + let pt = match v { + Value::String(s) => PrintSettings::parse_length(s), + v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), + }; + base.bleed = pt + .filter(|b| *b <= PrintSettings::MAX_BLEED) + .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; + } + } + for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(base) +} + +/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. +fn size_param(cmd: &str, p: &Value) -> Result> { + let size = match get(p, "size") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(s)) => ExportSize::parse(s), + Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), + Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), + }; + size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) +} + +/// Most files one `file.exportLayers` writes. +const MAX_EXPORT_FILES: usize = 1000; + +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +pub(super) fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} + +/// One file of a layer export: the layer, its setting and the file name. +pub struct ExportJob { + pub id: NodeId, + pub setting: ExportSetting, + pub file: String, +} + +/// The files exporting `ids` with their export settings makes, with unique names (repeats get +/// " 2", " 3"…). Layers without settings make none. +pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { + jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) +} + +/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). +pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { + let mut used = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for (id, settings) in items { + let Some(n) = doc.node(id) else { continue }; + let base = export_path(&n.name); + for e in settings { + if out.len() >= MAX_EXPORT_FILES { + return out; + } + let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); + let ext = e.format.ext(); + let file = + (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); + if let Some(file) = file { + out.push(ExportJob { id, setting: e, file }); + } + } + } + out +} + +/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and +/// `..` segments are dropped, so it stays inside the export folder. +fn export_path(name: &str) -> String { + let parts: Vec = + name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); + if parts.is_empty() { "Untitled".into() } else { parts.join("/") } +} + +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). +pub fn safe_file_name(name: &str) -> String { + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() +} + +/// Layers on `page` that have export settings (document order). +pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { + doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() +} + +pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result { + let cmd = "file.exportLayers"; + let doc = s.doc()?; + let assets = boolean(p, "assets").unwrap_or(false); + let mut ids = targets(s, p); + if ids.is_empty() { + ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; + } + let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; + if jobs.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); + } + let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); + // Print options passed here stand in for every PDF setting's own. + let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; + // And a compatibility mode for every Android Vector Drawable. + let compat = vd_compat_param(cmd, p)?; + let mut files = Vec::with_capacity(jobs.len()); + for job in &jobs { + let setting = + ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; + let scale = export_scale(doc, &[job.id], &setting); + let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; + let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); + if setting.format == ExportFormat::VectorDrawable { + f["warnings"] = json!(warnings); + } + match &dir { + Some(d) => { + let path = std::path::Path::new(d).join(&job.file); + // A layer name with slashes exports into folders. + if let Some(parent) = path.parent() { + let _ = crate::file_access::create_dir_all(parent); + } + let path = path.to_string_lossy().to_string(); + write(&path, &bytes)?; + f["path"] = json!(path); + } + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, + None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), + } + files.push(f); + } + let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) +} + +/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` +/// sizes a single page to that page area instead of the layers' bounds. +pub(super) fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { + let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; + figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) +} + +/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. +pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { + let mut frames = doc.top_frames(page); + frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); + if frames.is_empty() { + return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); + } + let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); + Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) +} + +/// Every top-level frame of a page (reading order), one PDF page each. +pub(super) fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let page = match id(p, "page") { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), + None => s.page()?, + }; + let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; + let (bytes, pages) = frames_pdf(doc, page, &print)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) + } + None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } +} diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs new file mode 100644 index 000000000..071c651ec --- /dev/null +++ b/crates/engine/src/cmd/file/load.rs @@ -0,0 +1,416 @@ +use super::*; + +/// The recovery copy at `path` as a document with unsaved changes and no path. +pub(crate) fn recovered(path: &str) -> Result { + let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; + Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read(path: &str) -> Result> { + crate::file_access::read(path) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read(path: &str) -> Result> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +/// The bytes of file `path`, refused when it's larger than `max`. +pub(crate) fn read_capped(path: &str, max: u64) -> Result> { + crate::file_access::read_capped(path, max) +} + +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { + crate::file_access::write(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +pub(super) fn ext(path: &str) -> String { + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() +} + +pub(super) fn file_stem(path: &str) -> String { + std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() +} + +/// The counts the import report shows for a freshly loaded document. +pub(super) fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + +/// Load a document from any supported file (REST API file JSON images come from an `images` +/// folder next to the file). +pub fn load(path: &str) -> Result<(Document, Vec)> { + load_path(path, None).map(|(l, _)| (l.doc, l.issues)) +} + +pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { + load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) +} + +/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST +/// import reads its variables from `variables`, else from a variables JSON next to the file. +pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { + let bytes = read(path)?; + let sibling = if variables.is_none() { sibling_variables(path) } else { None }; + load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) +} + +/// Load from bytes. Returns the document with its notes (repairs of a native file, or import +/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` +/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. +pub(super) fn load_with( + bytes: &[u8], + name_hint: &str, + variables: Option<&Value>, + images: figcraft_format::rest::ImageResolver, +) -> Result<(figcraft_format::Loaded, bool)> { + match ext(name_hint).as_str() { + "svg" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) + .map_err(|e| EngineError::Other(e.to_string()))? + .notes; + Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) + } + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; + let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; + doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + Ok((doc.into(), false)) + } + _ => { + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { + let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; + link_instances(&mut doc); + return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + } + let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { + loaded.doc.name = file_stem(name_hint); + } + Ok((loaded, false)) + } + } +} + +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); + platform_tracking(&mut doc); + sketch_line_heights(&mut doc); + link_instances(&mut doc); + reflow_overridden_text(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Instances of a stack sized apart from their main are laid out at their size, and texts in +/// instances whose string an override changed are measured again with the auto layout around them +/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout +/// Sketch saved. +fn reflow_overridden_text(doc: &mut Document) { + let changed: Vec = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } +} + +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. A named style's optical size follows the text size +/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { + style.variations.entry("opsz".into()).or_insert(style.font_size); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } +} + +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if !laid_out && down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Link imported instances to their mains (layer sources, overrides found by comparing values), +/// innermost first so outer instances settle last. +fn link_instances(doc: &mut Document) { + crate::cmd::styles::unfold_imported_bound_alpha(doc); + crate::style_vars::after_load(doc); + crate::cmd::styles::apply_paint_bindings_on_load(doc); + let mut insts: Vec<(usize, NodeId)> = + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); + insts.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (_, i) in insts { + crate::components::rebase_instance(doc, i); + } +} + +/// Largest image file read for a REST API file JSON import. +#[cfg(not(target_arch = "wasm32"))] +const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; + +/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to +/// the file. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn rest_images(path: &str) -> impl Fn(&str) -> Option> { + let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); + move |r: &str| { + // Image refs are content hashes; anything else never becomes a path. + if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + return None; + } + let dir = dir.as_ref()?; + ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] + .iter() + .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) + } +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { + figcraft_format::rest::no_images +} + +/// Largest variables JSON read from disk. +#[cfg(not(target_arch = "wasm32"))] +const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; + +/// A local variables JSON next to a REST API file JSON: `.variables.json`, else +/// `variables.json`. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn sibling_variables(path: &str) -> Option { + if ext(path) != "json" { + return None; + } + let file = std::path::Path::new(path); + let dir = file.parent()?; + [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { + let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; + figcraft_format::rest::is_variables_json(&v).then_some(v) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn sibling_variables(_path: &str) -> Option { + None +} + +/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. +pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { + let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); + let v = match get(p, "variables") { + None | Some(Value::Null) => return Ok(None), + Some(v @ Value::Object(_)) => v.clone(), + Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, + Some(Value::String(path)) => parse(&read(path)?)?, + Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), + }; + if !figcraft_format::rest::is_variables_json(&v) { + return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); + } + Ok(Some(v)) +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 000000000..6b724ced5 --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,245 @@ +use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param}; +use super::*; + +/// How many files File ▸ Open recent keeps. +pub const MAX_RECENT: usize = 20; + +/// Put `path` at the top of the recent files. +pub(super) fn remember(s: &mut Session, path: &str) { + let r = &mut s.prefs.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT); +} + +pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { + use crate::file_access::{Access, allow, allowed}; + const CMD: &str = "file.openRecent"; + // Confined to automation roots, the recent files outside them are never listed, opened, removed + // or named: `index` counts the files listed. + let listed = |r: &String| allowed(r, Access::Read); + if p.get("clear").and_then(Value::as_bool) == Some(true) { + s.prefs.recent_files.retain(|r| !listed(r)); + } + if let Some(gone) = string(p, "remove") { + s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); + } + let path = match (string(p, "path"), num(p, "index")) { + (Some(path), _) => { + allow(&path, Access::Read)?; + Some(path) + } + (None, Some(i)) => { + Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) + } + _ => None, + }; + if let Some(path) = path { + if !crate::file_access::exists(&path) { + s.prefs.recent_files.retain(|r| *r != path); + return Err(bad(CMD, format!("{path} no longer exists"))); + } + return open(s, &json!({"path": path})); + } + let files: Vec = s + .prefs + .recent_files + .iter() + .filter(|r| listed(r)) + .map(|r| { + let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); + json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) + }) + .collect(); + Ok(json!({"files": files})) +} + +pub(super) fn open(s: &mut Session, p: &Value) -> Result { + let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; + // Already open: switch to it. + if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { + s.active = i; + remember(s, &path); + return Ok(json!({"index": i})); + } + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); + let (loaded, rest) = load_path(&path, variables.as_ref())?; + remember(s, &path); + let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); + let warnings = rest || ext(&path) == "svg"; + // A native file that lost something (a damaged image, say): the app says so, not only the result. + if native && !loaded.issues.is_empty() { + s.message = Some(match loaded.issues.as_slice() { + [one] => format!("{}: {one}", file_stem(&path)), + more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), + }); + } + let summary = import_summary(&loaded.doc); + let images_left_out = if native { loaded.images_past_budget } else { 0 }; + let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, images_left_out); + ds.on_disk = on_disk.filter(|_| native); + s.docs.push(ds); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +pub(super) fn import(s: &mut Session, p: &Value) -> Result { + let path = string(p, "path"); + let variables = variables_param("file.import", p)?; + let (bytes, name) = if let Some(path) = &path { + (read(path)?, path.clone()) + } else if let Some(d) = string(p, "data") { + let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); + ( + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, + string(p, "name").unwrap_or_else(|| "import.png".into()), + ) + } else { + return Err(bad("file.import", "pass `path` or `data`")); + }; + let page = s.page()?; + let (x, y) = (num(p, "x"), num(p, "y")); + let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + match ext(&name).as_str() { + "svg" => { + let doc = s.doc_mut()?; + let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); + let imported = + figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; + let id = imported.frame; + // Centred on the point (a paste lands in the middle of the view). + if boolean(p, "center").unwrap_or(false) + && let Some(n) = doc.node_mut(id) + { + n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); + } + s.set_selection(vec![id]); + let mut out = json!({"id": id}); + if !imported.notes.is_empty() { + out["warnings"] = json!(imported.notes); + } + Ok(out) + } + "figcraft" | "json" | "fig" | "sketch" => { + let resolver = path.as_deref().map(rest_images); + let images: figcraft_format::rest::ImageResolver = match &resolver { + Some(r) => r, + None => &figcraft_format::rest::no_images, + }; + let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; + let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); + let pages = append_pages(s.doc_mut()?, &other.doc)?; + let mut out = json!({"pages": pages, "summary": summary}); + if rest { + out["warnings"] = json!(other.issues); + } + Ok(out) + } + _ => { + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + crate::cmd::create::place_image( + s, + &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}), + ) + } + } +} + +/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to +/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers +/// their source and interactions and flows their targets wherever those are. Styles, variables and +/// collections whose id the document already has are kept as they are. +pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result> { + for (k, v) in &other.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + for st in &other.styles { + if !doc.styles.iter().any(|s| s.id == st.id) { + doc.styles.push(st.clone()); + } + } + let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); + doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); + let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); + let mut map = std::collections::BTreeMap::new(); + for pg in &other.pages { + for id in other.subtree(*pg).into_iter().skip(1) { + map.insert(id, doc.alloc_id()); + } + } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } + let mut new_pages = Vec::new(); + for pg in &other.pages { + let Some(pn) = other.node(*pg) else { continue }; + let np = doc.add_page(&pn.name); + let mut kind = pn.kind.clone(); + if let NodeKind::Page { flows, .. } = &mut kind { + flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); + } + if let Some(n) = doc.node_mut(np) { + n.kind = kind; + } + for c in &pn.children { + let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *c, np, None)?; + } + new_pages.push(np); + } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } + for new in map.values() { + let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); + if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { + n.source = Some(src); + } + } + crate::library::merge_store(doc, other, &map)?; + Ok(new_pages) +} diff --git a/crates/engine/src/cmd/file/rest_import_tests.rs b/crates/engine/src/cmd/file/rest_import_tests.rs new file mode 100644 index 000000000..189edc605 --- /dev/null +++ b/crates/engine/src/cmd/file/rest_import_tests.rs @@ -0,0 +1,187 @@ +use super::*; + +/// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text +/// is overridden, and an image fill. +fn kit() -> Value { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let label = |id: &str, text: &str| { + json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, + "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) + }; + json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ + {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, + {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, + {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, + "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} + ]} + ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) +} + +fn instance(doc: &Document) -> &Node { + doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() +} + +/// What a .fig import with a cached library component leaves: the main in the hidden store, a +/// visible instance of it and a layer linked to the main's layer. +fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc +} + +/// Pasting layers that use a cached main into another document leaves the main behind, as it +/// does for other mains that are not published: no link points at a layer of the target. +#[test] +fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); +} + +#[test] +fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); +} + +#[test] +fn rest_json_loads_with_linked_instances() { + let bytes = serde_json::to_vec(&kit()).unwrap(); + let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); + assert_eq!(doc.name, "Kit"); + assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); + let inst = instance(&doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(inst.source, Some(component)); + let main_label = doc.node(component).unwrap().children[0]; + let label = doc.node(inst.children[0]).unwrap(); + assert_eq!(label.source, Some(main_label)); + assert!(label.overrides.contains("characters"), "{:?}", label.overrides); + assert!(crate::components::mirror_issues(&doc).is_empty()); + assert!(thumbnail(&doc).is_some()); +} + +#[test] +fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); +} + +#[test] +fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); +} + +#[test] +fn open_and_import_rest_json() { + let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); + std::fs::create_dir_all(dir.join("images")).unwrap(); + let png = { + let mut d = Document::new("x"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); + export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() + }; + std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); + let path = dir.join("kit.json"); + std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); + let path = path.to_string_lossy().to_string(); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path})).unwrap(); + assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); + assert!(r.get("repairs").is_none()); + assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); + let doc = s.doc().unwrap(); + let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); + let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); + + // Import into another document: pages are appended and references follow the new ids. + let mut s = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); + let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); + assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); + assert!(r["warnings"].is_array(), "{r}"); + let doc = s.doc().unwrap(); + assert_eq!(doc.pages.len(), 2); + let page = doc.node(doc.pages[1]).unwrap(); + assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); + let inst = instance(doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); + let label = doc.node(inst.children[0]).unwrap(); + assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); + assert!(crate::components::mirror_issues(doc).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 000000000..593c4c215 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,225 @@ +use super::export::export_fig; +use super::load::{ext, file_stem, write}; +use super::open::remember; +use super::*; + +/// Thumbnail PNG of the first page's content (for file browsers / the home screen). +pub fn thumbnail(doc: &Document) -> Option> { + let page = doc.first_page()?; + let b = doc.page_content_bounds(page)?; + let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; + figcraft_render::encode_png(&pix).ok() +} + +/// The `.figcraft` file for a document: content, thumbnail and version history. +pub fn file_bytes(ds: &DocState) -> Result> { + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; + refuse_dropping_images(ds, path)?; + refuse_overwriting_changes(ds, path)?; + write(path, bytes)?; + ds.note_on_disk(); + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { + match ds.images_lost_saving_to(path) { + Some(count) => Err(EngineError::Other(format!( + "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", + file_stem(path) + ))), + None => Ok(()), + } +} + +/// Saving over the tab's own file would lose what another app wrote to it since (#1330). +fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { + match (&ds.on_disk, crate::library::stat(path)) { + (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( + "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", + file_stem(path) + ))), + _ => Ok(()), + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } +} + +pub(super) fn color_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "file.colorProfile"; + let Some(name) = string(p, "profile") else { + s.request_ui("dialog.colorProfile", json!({})); + return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); + }; + let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; + let convert = boolean(p, "convert").unwrap_or(true); + let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); + Ok(json!({"profile": profile.id(), "changed": changed})) +} + +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { + return export_fig(s, &json!({"path": path})); + } + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + // A download (no path) writes over no file. + if let Some(path) = path.as_deref() { + refuse_dropping_images(ds, path)?; + } + let mut snapshot = FileSnapshot::of(ds); + let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); + } + let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + ds.note_on_disk(); + mark_saved(s, &json!({}))?; + remember(s, &path); + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +pub(super) fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + +pub(super) fn save(s: &mut Session, p: &Value) -> Result { + if let Some(path) = string(p, "path") { + return save_to(s, Some(path)); + } + let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; + match ds.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } + Some(path) => { + if p.get("overwrite").and_then(Value::as_bool) != Some(true) { + refuse_overwriting_changes(ds, &path)?; + } + save_to(s, Some(path)) + } + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) + } + } +} + +pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { + match string(p, "path") { + Some(mut path) => { + if ext(&path).is_empty() { + path.push_str(".figcraft"); + } + save_to(s, Some(path)) + } + None if wants_data(p) => save_to(s, None), + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) + } + } +} diff --git a/crates/engine/src/cmd/file/staging_write_tests.rs b/crates/engine/src/cmd/file/staging_write_tests.rs new file mode 100644 index 000000000..5653ce78d --- /dev/null +++ b/crates/engine/src/cmd/file/staging_write_tests.rs @@ -0,0 +1,119 @@ +use super::*; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/cmd/file/tab_order_tests.rs b/crates/engine/src/cmd/file/tab_order_tests.rs new file mode 100644 index 000000000..f884ee421 --- /dev/null +++ b/crates/engine/src/cmd/file/tab_order_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); +} diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs new file mode 100644 index 000000000..3d311a444 --- /dev/null +++ b/crates/engine/src/cmd/file/version.rs @@ -0,0 +1,172 @@ +use super::*; + +pub(super) fn version_json(index: usize, v: &Version) -> Value { + let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); + if v.auto { + j["auto"] = json!(true); + } + j +} + +/// Autosaves kept per file; older ones make room for new ones. +pub const MAX_AUTOSAVES: usize = 20; + +/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. +pub(super) fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; + let ds = s.ds_mut()?; + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { + return Ok(json!({"saved": false})); + } + Ok(match add_version(ds, AUTOSAVE, "", true) { + Some(v) => json!({"saved": true, "version": v}), + None => json!({"saved": false}), + }) +} + +/// What checkpoints are called. +const AUTOSAVE: &str = "Autosave"; + +/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest +/// autosave makes room when the autosaves or the history are full; versions the user named are +/// never dropped, so a history full of them takes nothing (None). +pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { + let autos = ds.versions.iter().filter(|v| v.auto).count(); + if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) + && let Some(oldest) = ds.versions.iter().position(|v| v.auto) + { + ds.versions_mut().remove(oldest); + } + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return None; + } + let mut v = snapshot_version(ds, name, description); + v.auto = auto; + let out = version_json(ds.versions.len(), &v); + ds.versions_mut().push(v); + ds.mark_checkpoint(); + Some(out) +} + +/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless +/// it is what the last version saved or restored holds. +pub(crate) fn keep_in_history(ds: &mut DocState) { + if ds.branch_of.is_none() && ds.changed_since_checkpoint() { + add_version(ds, AUTOSAVE, "", true); + } +} + +/// A version of `ds`'s document as it is now, opening on the page shown. +fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { + let mut snap = ds.doc.clone(); + if snap.current_page != Some(ds.page) { + Arc::make_mut(&mut snap).current_page = Some(ds.page); + } + Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, crate::cmd::comment::now_secs(), snap) +} + +pub(super) fn version_list(ds: &DocState) -> Value { + let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); + Value::Array( + ds.versions + .iter() + .enumerate() + .map(|(i, v)| { + let mut j = version_json(i, v); + if previewing == Some(v.id.as_str()) { + j["previewing"] = json!(true); + } + j + }) + .collect(), + ) +} + +/// The version named by `id`, else by `index` (validated: never negative, never past the end). +pub(super) fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { + if let Some(id) = string(p, "id") { + return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); + } + if get(p, "index").is_none() { + return Err(bad(cmd, "pass the version's `id` or `index`")); + } + let i = req_index(cmd, p, "index")?; + if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } +} + +pub(crate) fn read_version(v: &Version) -> Result> { + v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) +} + +/// A new version or branch id: the time in milliseconds (hex), not `taken`. +pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { + let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + loop { + let id = format!("{n:x}"); + if !taken(&id) { + return id; + } + n = n.wrapping_add(1); + } +} + +/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. +pub(super) fn save_version(s: &mut Session, p: &Value) -> Result { + let ds = s.ds_mut()?; + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return Err(EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + )); + } + let name = + string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); + let description = string(p, "description").unwrap_or_default(); + // The version opens on the page shown when it was saved. + add_version(ds, &name, description.trim(), false).ok_or_else(|| { + EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + ) + }) +} + +pub(super) fn restore_version(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "version.restore"; + // Refused before anything goes into the history. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } + let ds = s.ds_mut()?; + let i = version_index(CMD, ds, p)?; + let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; + let (id, mut doc) = (v.id.clone(), read_version(v)?); + // Restoring never loses work: the document as it is goes into the history first. + keep_in_history(ds); + let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); + let d = Arc::make_mut(&mut doc); + d.name = ds.doc.name.clone(); + // What the file published belongs to the file, not to the version. + d.published = ds.doc.published.clone(); + // Comments are a conversation about the file, kept apart from its design history: they stay, + // and those on layers the version doesn't have stay where they were. + d.comments = ds.doc.comments.clone(); + crate::cmd::comment::follow_anchors(d, &ds.doc); + // The snapshot is already consistent: nothing to re-normalise. + d.take_dirty(); + // Stay on the current page when the version has it. + ds.page = Some(ds.page) + .filter(|pg| d.pages.contains(pg)) + .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) + .or_else(|| d.first_page()) + .unwrap_or(ds.page); + ds.doc = doc; + ds.selection.clear(); + ds.text_edit = None; + ds.vector_edit = None; + // The restored design is in the history already: restoring it again keeps nothing new. + ds.mark_checkpoint(); + Ok(out) +} diff --git a/crates/engine/src/cmd/file/zip_cap_tests.rs b/crates/engine/src/cmd/file/zip_cap_tests.rs new file mode 100644 index 000000000..ee3e461ba --- /dev/null +++ b/crates/engine/src/cmd/file/zip_cap_tests.rs @@ -0,0 +1,11 @@ +use super::add_member; + +#[test] +fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); +} diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs deleted file mode 100644 index 5064c64f1..000000000 --- a/crates/engine/src/codegen.rs +++ /dev/null @@ -1,1755 +0,0 @@ -//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; -//! stacks for auto layout, absolute offsets otherwise). Components become components whose -//! properties are parameters and instances become calls passing their values (see -//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The -//! output is a starting point, not a pixel-exact translation. - -use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; -use serde_json::json; - -use crate::cmd::CommandSpec; -use crate::component_codegen::{Component, Param}; -use crate::params::*; -use crate::{bad, spec}; - -pub fn specs() -> Vec { - vec![spec!( - "inspect.code", - "Code for layer", - "{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", - false, - |s, p| { - let cmd = "inspect.code"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?, - None => CodeLang::Css, - }; - let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { - None | Some("px") => None, - Some("rem") => Some(num_or(p, "rootFontSize", 16.0)).filter(|r| r.is_finite() && *r > 0.0 && *r <= 1000.0), - Some(_) => return Err(bad(cmd, "unit: px|rem")), - }; - if string(p, "unit").is_some_and(|u| u.eq_ignore_ascii_case("rem")) && rem.is_none() { - return Err(bad(cmd, "rootFontSize: a positive number of pixels (up to 1000)")); - } - let doc = s.doc()?; - if !doc.contains(id) { - return Err(bad(cmd, "no such layer")); - } - let connected = crate::code_connect::link(doc, id, lang).is_some(); - let mobile = match get(p, "mobile") { - None | Some(serde_json::Value::Null) => None, - Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), - }; - let code = match (lang, mobile) { - (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), - _ => code_for(doc, id, lang), - }; - let code = match rem { - Some(root) if lang.is_web() => px_to_rem(&code, root), - _ => code, - }; - Ok(json!({"code": code, "language": lang.id(), "connected": connected})) - } - )] -} - -/// Languages the Inspect panel offers. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum CodeLang { - #[default] - Css, - /// The whole layer tree as nested HTML elements with a stylesheet. - Html, - /// A React function component (TSX) with its stylesheet. - React, - /// A Leptos component (`view!` markup) with its stylesheet. - Leptos, - /// A Vue single-file component (`