M0.5: layout (auto layout + constraints), format, svg, engine (commands, history, tools, components, styles, variables)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 12:40:18 -04:00
parent b367392c4b
commit 56a312ba5f
36 changed files with 10511 additions and 3 deletions

438
Cargo.lock generated
View File

@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@ -29,12 +38,39 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
@ -73,6 +109,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -82,6 +127,66 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "euclid"
version = "0.22.14"
@ -120,6 +225,38 @@ dependencies = [
"thiserror",
]
[[package]]
name = "figcraft-engine"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
"figcraft-doc",
"figcraft-format",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
"figcraft-render",
"figcraft-svg",
"figcraft-text",
"log",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "figcraft-format"
version = "0.1.0"
dependencies = [
"figcraft-doc",
"image",
"serde",
"serde_json",
"sha2",
"zip",
]
[[package]]
name = "figcraft-geom"
version = "0.1.0"
@ -128,6 +265,14 @@ dependencies = [
"serde",
]
[[package]]
name = "figcraft-layout"
version = "0.1.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
]
[[package]]
name = "figcraft-pathops"
version = "0.1.0"
@ -151,15 +296,28 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "figcraft-svg"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-render",
"usvg",
]
[[package]]
name = "figcraft-text"
version = "0.1.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust",
"harfrust 0.13.3",
"log",
"skrifa",
"skrifa 0.47.0",
]
[[package]]
@ -173,6 +331,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "font-types"
version = "0.12.6"
@ -182,6 +346,38 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gif"
version = "0.14.2"
@ -192,6 +388,18 @@ dependencies = [
"weezl",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags",
"bytemuck",
"read-fonts 0.41.0",
"smallvec",
]
[[package]]
name = "harfrust"
version = "0.13.3"
@ -205,6 +413,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "image"
version = "0.25.10"
@ -233,6 +447,22 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -252,6 +482,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linesweeper"
version = "0.4.0"
@ -277,6 +513,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -322,6 +567,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "png"
version = "0.18.1"
@ -374,6 +625,17 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "read-fonts"
version = "0.43.3"
@ -396,6 +658,21 @@ dependencies = [
"once_cell",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -445,12 +722,48 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.47.0"
@ -461,6 +774,15 @@ dependencies = [
"read-fonts 0.44.0",
]
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.16.2"
@ -475,6 +797,19 @@ name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "syn"
@ -533,18 +868,88 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "usvg"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"base64 0.23.1",
"data-url",
"flate2",
"fontdb",
"harfrust 0.12.0",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.21.1",
"simplecss",
"siphasher",
"skrifa 0.44.0",
"strict-num",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.1.0"
@ -552,6 +957,23 @@ dependencies = [
"serde_json",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@ -564,6 +986,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"

25
crates/engine/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "figcraft-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft engine: sessions, undo history, the command registry every surface (UI, CLI, control channel, MCP) dispatches through, pointer tools, components and the Figma menu catalog"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-layout = { workspace = true }
figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }

View File

@ -0,0 +1,284 @@
//! The incumbent's main-menu tree with stable FigCraft command ids. A command registered under a
//! catalog id lights up its menu item, and `cargo xtask parity` measures coverage against it.
//!
//! Entries: (menu path, label, command id, default shortcut). `/` separates submenus; a label of
//! `-` is a separator.
pub struct MenuItem {
pub menu: &'static str,
pub label: &'static str,
pub id: &'static str,
pub shortcut: &'static str,
}
macro_rules! m {
($menu:expr, $label:expr, $id:expr, $sc:expr) => {
MenuItem { menu: $menu, label: $label, id: $id, shortcut: $sc }
};
}
pub const SEP: &str = "-";
pub static MENU: &[MenuItem] = &[
// File
m!("File", "New design file", "file.new", "Cmd+N"),
m!("File", "Open…", "file.open", "Cmd+O"),
m!("File", "Import from file…", "file.import", "Shift+Cmd+I"),
m!("File", "Place image…", "file.placeImage", "Shift+Cmd+K"),
m!("File", SEP, "", ""),
m!("File", "Save", "file.save", "Cmd+S"),
m!("File", "Save as…", "file.saveAs", "Shift+Cmd+S"),
m!("File", "Save to version history…", "file.saveVersion", "Alt+Cmd+S"),
m!("File", "Show version history", "file.versionHistory", ""),
m!("File", SEP, "", ""),
m!("File", "Export…", "file.export", "Shift+Cmd+E"),
m!("File", "Export frames to PDF…", "file.exportFramesPdf", ""),
m!("File", "Export selection as SVG…", "file.exportSvg", ""),
m!("File", SEP, "", ""),
m!("File", "Duplicate", "file.duplicate", ""),
m!("File", "Rename", "file.rename", ""),
m!("File", "Revert to saved", "file.revert", ""),
m!("File", "Close tab", "file.close", "Cmd+W"),
m!("File", "Open recent", "file.openRecent", ""),
// Edit
m!("Edit", "Undo", "edit.undo", "Cmd+Z"),
m!("Edit", "Redo", "edit.redo", "Shift+Cmd+Z"),
m!("Edit", SEP, "", ""),
m!("Edit/Copy as", "Copy as text", "edit.copyAsText", ""),
m!("Edit/Copy as", "Copy as CSS", "edit.copyAsCss", ""),
m!("Edit/Copy as", "Copy as SVG", "edit.copyAsSvg", ""),
m!("Edit/Copy as", "Copy as PNG", "edit.copyAsPng", "Shift+Cmd+C"),
m!("Edit/Copy as", "Copy link to selection", "edit.copyLink", ""),
m!("Edit", "Cut", "edit.cut", "Cmd+X"),
m!("Edit", "Copy", "edit.copy", "Cmd+C"),
m!("Edit", "Paste", "edit.paste", "Cmd+V"),
m!("Edit", "Paste over selection", "edit.pasteOver", "Shift+Cmd+V"),
m!("Edit", "Paste to replace", "edit.pasteReplace", "Shift+Cmd+R"),
m!("Edit", "Duplicate", "edit.duplicate", "Cmd+D"),
m!("Edit", "Delete", "edit.delete", "Delete"),
m!("Edit", SEP, "", ""),
m!("Edit", "Find and replace…", "edit.findReplace", "Cmd+F"),
m!("Edit", "Copy properties", "edit.copyProperties", "Alt+Cmd+C"),
m!("Edit", "Paste properties", "edit.pasteProperties", "Alt+Cmd+V"),
m!("Edit", "Pick color", "edit.pickColor", "I"),
m!("Edit", "Set default properties", "edit.setDefaultProperties", ""),
m!("Edit", SEP, "", ""),
m!("Edit", "Select all", "select.all", "Cmd+A"),
m!("Edit", "Select matching layers", "select.matching", "Alt+Cmd+A"),
m!("Edit", "Select none", "select.none", "Escape"),
m!("Edit", "Select inverse", "select.inverse", "Shift+Cmd+A"),
m!("Edit/Select all with same", "Fill", "select.sameFill", ""),
m!("Edit/Select all with same", "Stroke", "select.sameStroke", ""),
m!("Edit/Select all with same", "Effect", "select.sameEffect", ""),
m!("Edit/Select all with same", "Text properties", "select.sameText", ""),
m!("Edit/Select all with same", "Font", "select.sameFont", ""),
m!("Edit/Select all with same", "Instance", "select.sameInstance", ""),
m!("Edit/Select all with same", "Properties", "select.sameProperties", ""),
// View
m!("View", "Pixel grid", "view.pixelGrid", "Shift+'"),
m!("View", "Layout grids", "view.layoutGrids", "Ctrl+G"),
m!("View", "Rulers", "view.rulers", "Shift+R"),
m!("View", "Slices", "view.slices", ""),
m!("View", "Comments", "view.comments", "Shift+C"),
m!("View", SEP, "", ""),
m!("View/Outlines", "Show outlines", "view.outlines", "Cmd+Y"),
m!("View/Outlines", "Show outlines for selection", "view.outlinesSelection", ""),
m!("View", "Pixel preview", "view.pixelPreview", ""),
m!("View", "Mask outlines", "view.maskOutlines", ""),
m!("View", "Frame outlines", "view.frameOutlines", ""),
m!("View", SEP, "", ""),
m!("View/Panels", "Show/Hide UI", "view.toggleUi", "Cmd+\\"),
m!("View/Panels", "Layers", "view.layers", "Alt+1"),
m!("View/Panels", "Assets", "view.assets", "Alt+2"),
m!("View/Panels", "Design", "view.design", "Alt+8"),
m!("View/Panels", "Prototype", "view.prototype", "Alt+9"),
m!("View", SEP, "", ""),
m!("View", "Zoom in", "view.zoomIn", "Cmd+="),
m!("View", "Zoom out", "view.zoomOut", "Cmd+-"),
m!("View", "Zoom to 100%", "view.zoom100", "Shift+0"),
m!("View", "Zoom to 50%", "view.zoom50", ""),
m!("View", "Zoom to 200%", "view.zoom200", ""),
m!("View", "Zoom to fit", "view.zoomFit", "Shift+1"),
m!("View", "Zoom to selection", "view.zoomSelection", "Shift+2"),
m!("View", "Zoom to previous frame", "view.prevFrame", "Shift+N"),
m!("View", "Zoom to next frame", "view.nextFrame", "N"),
m!("View", SEP, "", ""),
m!("View", "Previous page", "view.prevPage", "PageUp"),
m!("View", "Next page", "view.nextPage", "PageDown"),
m!("View/Theme", "Light", "view.themeLight", ""),
m!("View/Theme", "Dark", "view.themeDark", ""),
m!("View/Theme", "Follow system", "view.themeSystem", ""),
// Object
m!("Object", "Frame selection", "object.frameSelection", "Alt+Cmd+G"),
m!("Object", "Group selection", "object.group", "Cmd+G"),
m!("Object", "Ungroup selection", "object.ungroup", "Shift+Cmd+G"),
m!("Object", "Remove frame", "object.removeFrame", ""),
m!("Object", SEP, "", ""),
m!("Object", "Add auto layout", "layout.add", "Shift+A"),
m!("Object", "Remove auto layout", "layout.remove", "Alt+Shift+A"),
m!("Object", "Suggest auto layout", "layout.suggest", ""),
m!("Object", SEP, "", ""),
m!("Object", "Create component", "component.create", "Alt+Cmd+K"),
m!("Object", "Create multiple components", "component.createMultiple", ""),
m!("Object", "Combine as variants", "component.combineVariants", ""),
m!("Object", "Reset all changes", "component.resetInstance", ""),
m!("Object", "Detach instance", "component.detach", "Alt+Cmd+B"),
m!("Object", "Go to main component", "component.goToMain", ""),
m!("Object", "Push changes to main component", "component.pushChanges", ""),
m!("Object", "Restore main component", "component.restoreMain", ""),
m!("Object", SEP, "", ""),
m!("Object", "Use as mask", "object.mask", "Ctrl+Cmd+M"),
m!("Object", "Release mask", "object.releaseMask", ""),
m!("Object", "Set as thumbnail", "object.setThumbnail", ""),
m!("Object", SEP, "", ""),
m!("Object", "Bring to front", "arrange.front", "Alt+Cmd+]"),
m!("Object", "Bring forward", "arrange.forward", "Cmd+]"),
m!("Object", "Send backward", "arrange.backward", "Cmd+["),
m!("Object", "Send to back", "arrange.back", "Alt+Cmd+["),
m!("Object", SEP, "", ""),
m!("Object", "Flip horizontal", "object.flipH", "Shift+H"),
m!("Object", "Flip vertical", "object.flipV", "Shift+V"),
m!("Object", "Rotate 180°", "object.rotate180", ""),
m!("Object", "Rotate 90° left", "object.rotateLeft", ""),
m!("Object", "Rotate 90° right", "object.rotateRight", ""),
m!("Object", SEP, "", ""),
m!("Object", "Flatten", "vector.flatten", "Cmd+E"),
m!("Object", "Outline stroke", "vector.outlineStroke", "Alt+Cmd+O"),
m!("Object/Boolean groups", "Union", "bool.union", ""),
m!("Object/Boolean groups", "Subtract", "bool.subtract", ""),
m!("Object/Boolean groups", "Intersect", "bool.intersect", ""),
m!("Object/Boolean groups", "Exclude", "bool.exclude", ""),
m!("Object/Boolean groups", "Flatten", "bool.flatten", ""),
m!("Object", "Rasterize selection", "object.rasterize", ""),
m!("Object", SEP, "", ""),
m!("Object", "Show/Hide selection", "object.toggleVisible", "Shift+Cmd+H"),
m!("Object", "Lock/Unlock selection", "object.toggleLock", "Shift+Cmd+L"),
m!("Object", "Hide other layers", "object.hideOthers", ""),
m!("Object", "Collapse layers", "layers.collapseAll", "Alt+L"),
m!("Object", SEP, "", ""),
m!("Object", "Remove fill", "object.removeFill", ""),
m!("Object", "Remove stroke", "object.removeStroke", ""),
m!("Object", "Swap fill and stroke", "object.swapFillStroke", "Shift+X"),
m!("Object", SEP, "", ""),
m!("Object", "Rename", "object.rename", "Cmd+R"),
m!("Object", "Batch rename…", "object.batchRename", ""),
m!("Object", "Move to page", "object.moveToPage", ""),
m!("Object/Select layer", "Select children", "select.children", "Enter"),
m!("Object/Select layer", "Select parent", "select.parent", "Shift+Enter"),
m!("Object/Select layer", "Select next sibling", "select.next", "Tab"),
m!("Object/Select layer", "Select previous sibling", "select.previous", "Shift+Tab"),
// Text
m!("Text", "Bold", "text.bold", "Cmd+B"),
m!("Text", "Italic", "text.italic", "Cmd+I"),
m!("Text", "Underline", "text.underline", "Cmd+U"),
m!("Text", "Strikethrough", "text.strikethrough", "Shift+Cmd+X"),
m!("Text", "Create link", "text.link", "Cmd+K"),
m!("Text", SEP, "", ""),
m!("Text", "Bulleted list", "text.bulletList", "Shift+Cmd+8"),
m!("Text", "Numbered list", "text.numberList", "Shift+Cmd+7"),
m!("Text", SEP, "", ""),
m!("Text/Alignment", "Text align left", "text.alignLeft", "Alt+Cmd+L"),
m!("Text/Alignment", "Text align center", "text.alignCenter", "Alt+Cmd+T"),
m!("Text/Alignment", "Text align right", "text.alignRight", "Alt+Cmd+R"),
m!("Text/Alignment", "Text align justified", "text.alignJustify", "Alt+Cmd+J"),
m!("Text/Adjust", "Increase font size", "text.sizeUp", "Shift+Cmd+."),
m!("Text/Adjust", "Decrease font size", "text.sizeDown", "Shift+Cmd+,"),
m!("Text/Adjust", "Increase font weight", "text.weightUp", "Alt+Cmd+."),
m!("Text/Adjust", "Decrease font weight", "text.weightDown", "Alt+Cmd+,"),
m!("Text/Adjust", "Increase line height", "text.lineHeightUp", "Alt+Shift+."),
m!("Text/Adjust", "Decrease line height", "text.lineHeightDown", "Alt+Shift+,"),
m!("Text/Adjust", "Increase letter spacing", "text.trackingUp", "Alt+."),
m!("Text/Adjust", "Decrease letter spacing", "text.trackingDown", "Alt+,"),
m!("Text/Case", "Original case", "text.caseOriginal", ""),
m!("Text/Case", "Uppercase", "text.caseUpper", ""),
m!("Text/Case", "Lowercase", "text.caseLower", ""),
m!("Text/Case", "Title case", "text.caseTitle", ""),
// Arrange
m!("Arrange", "Align left", "align.left", "Alt+A"),
m!("Arrange", "Align horizontal centers", "align.hcenter", "Alt+H"),
m!("Arrange", "Align right", "align.right", "Alt+D"),
m!("Arrange", "Align top", "align.top", "Alt+W"),
m!("Arrange", "Align vertical centers", "align.vcenter", "Alt+V"),
m!("Arrange", "Align bottom", "align.bottom", "Alt+S"),
m!("Arrange", SEP, "", ""),
m!("Arrange", "Distribute horizontal spacing", "distribute.horizontal", "Alt+Shift+H"),
m!("Arrange", "Distribute vertical spacing", "distribute.vertical", "Alt+Shift+V"),
m!("Arrange", "Tidy up", "arrange.tidy", "Ctrl+Alt+T"),
m!("Arrange", SEP, "", ""),
m!("Arrange", "Pack horizontal", "arrange.packH", ""),
m!("Arrange", "Pack vertical", "arrange.packV", ""),
m!("Arrange", SEP, "", ""),
m!("Arrange", "Round to pixels", "arrange.roundToPixels", ""),
// Vector
m!("Vector", "Join selection", "vector.join", "Cmd+J"),
m!("Vector", "Smooth join selection", "vector.smoothJoin", "Shift+Cmd+J"),
m!("Vector", "Delete and heal selection", "vector.deleteHeal", "Shift+Delete"),
m!("Vector", "Simplify vector", "vector.simplify", ""),
m!("Vector", "Reverse path direction", "vector.reverse", ""),
m!("Vector", "Offset path", "vector.offset", ""),
// Plugins
m!("Plugins", "Run last plugin", "plugins.runLast", "Alt+Cmd+P"),
m!("Plugins", "Manage plugins…", "plugins.manage", ""),
// Preferences
m!("Preferences", "Snap to geometry", "view.snapGeometry", ""),
m!("Preferences", "Snap to objects", "view.snapObjects", ""),
m!("Preferences", "Snap to pixel grid", "view.snapPixel", "Shift+Cmd+'"),
m!("Preferences", "Keep tool selected after use", "pref.keepTool", ""),
m!("Preferences", "Highlight layers on hover", "pref.highlightHover", ""),
m!("Preferences", "Rename duplicated layers", "pref.renameDuplicates", ""),
m!("Preferences", "Show dimensions on objects", "pref.showDimensions", ""),
m!("Preferences", "Use number keys for opacity", "pref.numberKeysOpacity", ""),
m!("Preferences", "Nudge amount…", "pref.nudge", ""),
m!("Preferences", "Preferences…", "app.preferences", "Cmd+,"),
// Libraries
m!("Libraries", "Open libraries", "libraries.open", "Alt+3"),
m!("Libraries", "Publish library…", "libraries.publish", ""),
// Help
m!("Help", "Keyboard shortcuts", "help.shortcuts", "Ctrl+Shift+?"),
m!("Help", "Help page", "help.page", ""),
m!("Help", "About FigCraft", "help.about", ""),
// Toolbar
m!("Tools", "Move", "tool.move", "V"),
m!("Tools", "Scale", "tool.scale", "K"),
m!("Tools", "Hand", "tool.hand", "H"),
m!("Tools", "Frame", "tool.frame", "F"),
m!("Tools", "Section", "tool.section", "Shift+S"),
m!("Tools", "Slice", "tool.slice", "S"),
m!("Tools", "Rectangle", "tool.rectangle", "R"),
m!("Tools", "Line", "tool.line", "L"),
m!("Tools", "Arrow", "tool.arrow", "Shift+L"),
m!("Tools", "Ellipse", "tool.ellipse", "O"),
m!("Tools", "Polygon", "tool.polygon", ""),
m!("Tools", "Star", "tool.star", ""),
m!("Tools", "Pen", "tool.pen", "P"),
m!("Tools", "Pencil", "tool.pencil", "Shift+P"),
m!("Tools", "Text", "tool.text", "T"),
m!("Tools", "Comment", "tool.comment", "C"),
m!("Tools", "Actions", "app.actions", "Cmd+K"),
];
/// Items that are real commands (not separators).
pub fn items() -> impl Iterator<Item = &'static MenuItem> {
MENU.iter().filter(|m| m.label != SEP)
}
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
let mut done = 0;
let mut missing = Vec::new();
let mut total = 0;
for m in items() {
total += 1;
if crate::find_command(m.id).is_some() || extra.contains(&m.id) {
done += 1;
} else {
missing.push(m.id);
}
}
(done, total, missing)
}
/// The default shortcut for a command id.
pub fn shortcut(id: &str) -> Option<&'static str> {
MENU.iter().find(|m| m.id == id && !m.shortcut.is_empty()).map(|m| m.shortcut)
}

View File

@ -0,0 +1,416 @@
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
use figcraft_doc::*;
use figcraft_geom::{Affine, Point, Rect};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("align.left", "Align left", "{ids?}", true, |s, p| align(s, p, Al::Left)),
spec!("align.hcenter", "Align horizontal centers", "{ids?}", true, |s, p| align(s, p, Al::HCenter)),
spec!("align.right", "Align right", "{ids?}", true, |s, p| align(s, p, Al::Right)),
spec!("align.top", "Align top", "{ids?}", true, |s, p| align(s, p, Al::Top)),
spec!("align.vcenter", "Align vertical centers", "{ids?}", true, |s, p| align(s, p, Al::VCenter)),
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
spec!("arrange.front", "Bring to front", "{ids?}", true, |s, p| zorder(s, p, Z::Front)),
spec!("arrange.forward", "Bring forward", "{ids?}", true, |s, p| zorder(s, p, Z::Forward)),
spec!("arrange.backward", "Send backward", "{ids?}", true, |s, p| zorder(s, p, Z::Backward)),
spec!("arrange.back", "Send to back", "{ids?}", true, |s, p| zorder(s, p, Z::Back)),
spec!("object.flipH", "Flip horizontal", "{ids?}", true, |s, p| flip(s, p, true)),
spec!("object.flipV", "Flip vertical", "{ids?}", true, |s, p| flip(s, p, false)),
spec!("object.rotate180", "Rotate 180°", "{ids?}", true, |s, p| rotate_by(s, p, 180.0)),
spec!("object.rotateLeft", "Rotate 90° left", "{ids?}", true, |s, p| rotate_by(s, p, 90.0)),
spec!("object.rotateRight", "Rotate 90° right", "{ids?}", true, |s, p| rotate_by(s, p, -90.0)),
spec!("transform.move", "Move", "{ids?, dx, dy} (page units)", true, |s, p| {
let (dx, dy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
let ids = req_targets("transform.move", s, p)?;
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
move_world(doc, i, dx, dy)?;
}
ok()
}),
spec!("transform.moveTo", "Move to", "{ids?, x, y} (page coordinates of the selection's top-left)", true, |s, p| {
let ids = req_targets("transform.moveTo", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let b = doc.bounds_of(&ids).ok_or_else(|| bad("transform.moveTo", "nothing to move"))?;
let (dx, dy) = (num_or(p, "x", b.x0) - b.x0, num_or(p, "y", b.y0) - b.y0);
for i in ids {
move_world(doc, i, dx, dy)?;
}
ok()
}),
spec!("transform.rotate", "Rotate", "{ids?, degrees, cx?, cy? (pivot, page coords; default the selection centre)}", true, |s, p| {
let deg = req_num("transform.rotate", p, "degrees")?;
let ids = req_targets("transform.rotate", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let c = doc.bounds_of(&ids).map(|b| b.center()).unwrap_or_default();
let pivot = Point::new(num_or(p, "cx", c.x), num_or(p, "cy", c.y));
for i in ids {
rotate_world(doc, i, pivot, deg)?;
}
ok()
}),
spec!("transform.scale", "Scale", "{ids?, factor, cx?, cy? (pivot; default top-left)} — scales size, strokes, effects, text (the K tool)", true, scale_cmd),
spec!("transform.resize", "Resize", "{ids?, width?, height?, anchor? (tl|t|tr|l|c|r|bl|b|br)}", true, resize_cmd),
]
}
#[derive(Clone, Copy)]
enum Al {
Left,
HCenter,
Right,
Top,
VCenter,
Bottom,
}
/// Move a node by `(dx, dy)` in page units.
pub fn move_world(doc: &mut Document, id: NodeId, dx: f64, dy: f64) -> Result<()> {
let n = doc.get(id)?;
let pw = n.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
let d = pw.inverse() * Affine::translate((dx, dy)) * pw;
let n = doc.get_mut(id)?;
n.transform = d * n.transform;
Ok(())
}
pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> Result<()> {
let world = doc.world_transform(id);
let nw = figcraft_geom::rotate_about(world, pivot, deg);
doc.set_world_transform(id, nw)?;
Ok(())
}
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
let ids = req_targets("align", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
// One layer aligns within its parent frame; several align to their combined bounds.
let target: Rect = if ids.len() == 1 {
let Some(id) = ids.first() else { return ok() };
let parent = doc.get(*id)?.parent;
match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) {
Some(pp) => doc.world_bounds(pp).unwrap_or_default(),
None => return ok(),
}
} else {
doc.bounds_of(&ids).unwrap_or_default()
};
for i in ids {
let Some(b) = doc.world_bounds(i) else { continue };
let (dx, dy) = match a {
Al::Left => (target.x0 - b.x0, 0.0),
Al::HCenter => (target.center().x - b.center().x, 0.0),
Al::Right => (target.x1 - b.x1, 0.0),
Al::Top => (0.0, target.y0 - b.y0),
Al::VCenter => (0.0, target.center().y - b.center().y),
Al::Bottom => (0.0, target.y1 - b.y1),
};
move_world(doc, i, dx, dy)?;
}
ok()
}
fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let ids = req_targets("distribute", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
if ids.len() < 3 && num(p, "spacing").is_none() {
return ok();
}
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
for (i, b) in items.clone() {
if horizontal {
move_world(doc, i, pos - b.x0, 0.0)?;
pos += b.width() + gap;
} else {
move_world(doc, i, 0.0, pos - b.y0)?;
pos += b.height() + gap;
}
}
ok()
}
/// Tidy up: snap the selection into a grid of rows/columns with even spacing.
fn tidy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.tidy", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
if items.len() < 2 {
return ok();
}
// Group into rows by vertical centre overlap.
items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
let mut rows: Vec<Vec<(NodeId, Rect)>> = Vec::new();
for it in items {
match rows.last_mut() {
Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it),
_ => rows.push(vec![it]),
}
}
let all: Vec<Rect> = rows.iter().flatten().map(|(_, b)| *b).collect();
let (Some(origin), maxw, maxh) = (all.iter().copied().reduce(|a, b| a.union(b)), all.iter().map(Rect::width).fold(0.0, f64::max), all.iter().map(Rect::height).fold(0.0, f64::max)) else {
return ok();
};
let gap = num(p, "spacing").unwrap_or_else(|| {
// Keep the average existing horizontal gap (min 10).
10f64.max(rows.iter().filter(|r| r.len() > 1).map(|r| (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) - r.iter().map(|(_, b)| b.width()).sum::<f64>()) / (r.len() - 1) as f64).fold(0.0, f64::max))
});
for (ri, row) in rows.iter_mut().enumerate() {
row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
for (ci, (id, b)) in row.iter().enumerate() {
let x = origin.x0 + ci as f64 * (maxw + gap);
let y = origin.y0 + ri as f64 * (maxh + gap);
move_world(doc, *id, x - b.x0, y - b.y0)?;
}
}
ok()
}
fn pack(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("spacing".into(), json!(0.0));
}
distribute(s, &q, horizontal)
}
fn round_px(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.roundToPixels", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4].round(), c[5].round()]);
n.width = n.width.round().max(if n.width > 0.0 { 1.0 } else { 0.0 });
n.height = n.height.round().max(if n.height > 0.0 { 1.0 } else { 0.0 });
}
ok()
}
#[derive(Clone, Copy)]
enum Z {
Front,
Forward,
Backward,
Back,
}
fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
let ids = req_targets("arrange", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let order: Vec<NodeId> = match z {
Z::Front | Z::Forward => ids.iter().rev().copied().collect(),
_ => ids.clone(),
};
for i in order {
let Some(parent) = doc.get(i)?.parent else { continue };
let len = doc.get(parent)?.children.len();
let cur = doc.index_in_parent(i).unwrap_or(0);
let siblings_selected = |k: usize, doc: &Document| doc.node(parent).and_then(|pn| pn.children.get(k)).is_some_and(|c| ids.contains(c));
let target = match z {
Z::Front => len.saturating_sub(1),
Z::Back => 0,
Z::Forward => {
if cur + 1 < len && !siblings_selected(cur + 1, doc) {
cur + 1
} else {
cur
}
}
Z::Backward => {
if cur > 0 && !siblings_selected(cur - 1, doc) {
cur - 1
} else {
cur
}
}
};
doc.reorder(i, target)?;
}
// Front/Back of several keep their relative order.
if matches!(z, Z::Front) {
for i in &ids {
let parent = doc.get(*i)?.parent;
if let Some(pp) = parent {
let len = doc.get(pp)?.children.len();
doc.reorder(*i, len)?;
}
}
}
if matches!(z, Z::Back) {
for (k, i) in ids.iter().enumerate() {
doc.reorder(*i, k)?;
}
}
ok()
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let ids = req_targets("flip", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let Some(b) = doc.bounds_of(&ids) else { return ok() };
let c = b.center();
let m = if horizontal { Affine::new([-1.0, 0.0, 0.0, 1.0, 2.0 * c.x, 0.0]) } else { Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 2.0 * c.y]) };
for i in ids {
let w = doc.world_transform(i);
doc.set_world_transform(i, m * w)?;
}
ok()
}
fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result<Value> {
let ids = req_targets("rotate", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let Some(b) = doc.bounds_of(&ids) else { return ok() };
for i in ids {
rotate_world(doc, i, b.center(), deg)?;
}
ok()
}
/// Scale nodes uniformly including strokes, effects, radii and text sizes (the Scale tool).
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
let sub = doc.subtree(id);
for (depth, i) in sub.iter().enumerate() {
let n = doc.get_mut(*i)?;
n.stroke.weight *= k;
if let Some(sides) = n.stroke.sides.as_mut() {
for v in sides.iter_mut() {
*v *= k;
}
}
for d in &mut n.stroke.dashes {
*d *= k;
}
n.corners.radii = n.corners.radii.map(|r| r * k);
for e in &mut n.effects {
match &mut e.kind {
EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => {
sh.radius *= k;
sh.spread *= k;
sh.offset = (sh.offset.0 * k, sh.offset.1 * k);
}
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => *radius *= k,
_ => {}
}
}
if let Some(l) = n.layout.as_mut() {
l.gap *= k;
l.cross_gap *= k;
l.padding = l.padding.map(|x| x * k);
}
if let NodeKind::Text(t) = &mut n.kind {
t.style.font_size *= k;
if let LineHeight::Pixels(v) = &mut t.style.line_height {
*v *= k;
}
if let LetterSpacing::Pixels(v) = &mut t.style.letter_spacing {
*v *= k;
}
for r in &mut t.runs {
r.style.font_size *= k;
if let LineHeight::Pixels(v) = &mut r.style.line_height {
*v *= k;
}
}
t.paragraph_spacing *= k;
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
}
n.width *= k;
n.height *= k;
if depth > 0 {
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
}
}
Ok(())
}
fn scale_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let k = req_num("transform.scale", p, "factor")?;
if k <= 0.0 {
return Err(bad("transform.scale", "`factor` must be positive"));
}
let ids = req_targets("transform.scale", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let b = doc.bounds_of(&ids).unwrap_or_default();
let pivot = Point::new(num_or(p, "cx", b.x0), num_or(p, "cy", b.y0));
for i in ids {
let Some(nb) = doc.world_bounds(i) else { continue };
scale_node(doc, i, k)?;
// Keep relative position to the pivot.
let target = Point::new(pivot.x + (nb.x0 - pivot.x) * k, pivot.y + (nb.y0 - pivot.y) * k);
if let Some(after) = doc.world_bounds(i) {
move_world(doc, i, target.x - after.x0, target.y - after.y0)?;
}
}
ok()
}
fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("transform.resize", s, p)?;
let anchor = string(p, "anchor").unwrap_or_else(|| "tl".into());
let (ax, ay) = match anchor.as_str() {
"t" => (0.5, 0.0),
"tr" => (1.0, 0.0),
"l" => (0.0, 0.5),
"c" => (0.5, 0.5),
"r" => (1.0, 0.5),
"bl" => (0.0, 1.0),
"b" => (0.5, 1.0),
"br" => (1.0, 1.0),
_ => (0.0, 0.0),
};
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get(i)?;
let (ow, oh) = (n.width, n.height);
let (w, h) = (num(p, "width").unwrap_or(ow).max(0.0), num(p, "height").unwrap_or(oh).max(0.0));
let fixed_local = Point::new(ow * ax, oh * ay);
let before = n.transform * fixed_local;
super::props::resize_node(doc, i, w, h)?;
let n = doc.get_mut(i)?;
let after = n.transform * Point::new(w * ax, h * ay);
n.transform = Affine::translate(before - after) * n.transform;
if num(p, "width").is_some() {
n.child_layout.sizing_h = Sizing::Fixed;
}
if num(p, "height").is_some() {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
}
}
ok()
}

View File

@ -0,0 +1,309 @@
//! Component commands.
use figcraft_doc::*;
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::components::*;
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("component.create", "Create component", "{ids?} (one frame/group becomes a component; several layers are wrapped in one)", true, create),
spec!("component.createMultiple", "Create multiple components", "{ids?}", true, |s, p| {
let ids = req_targets("component.createMultiple", s, p)?;
let mut out = Vec::new();
for i in ids {
let r = create(s, &json!({"ids": [i]}))?;
out.push(r["id"].clone());
}
s.set_selection(out.iter().filter_map(id_of).collect());
Ok(json!({"ids": out}))
}),
spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
spec!("instance.create", "Create instance", "{component, x?, y?, parent?}", true, |s, p| {
let c = req_id("instance.create", p, "component")?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let doc = s.doc_mut()?;
let (x, y) = match (num(p, "x"), num(p, "y")) {
(Some(x), Some(y)) => (x, y),
_ => doc.world_bounds(c).map(|b| (b.x1 + 40.0, b.y0)).unwrap_or((0.0, 0.0)),
};
let i = create_instance(doc, c, parent, x, y)?;
s.set_selection(vec![i]);
Ok(json!({"id": i}))
}),
spec!("component.detach", "Detach instance", "{ids?}", true, detach),
spec!("component.resetInstance", "Reset all changes", "{ids?}", true, |s, p| {
for i in req_targets("component.resetInstance", s, p)? {
let doc = s.doc_mut()?;
let inst = owning_instance(doc, i).unwrap_or(i);
for d in doc.subtree(inst) {
if let Some(n) = doc.node_mut(d) {
n.overrides.clear();
}
}
sync_instance(doc, inst);
}
ok()
}),
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
let i = targets(s, p).first().copied().ok_or_else(|| bad("component.goToMain", "select an instance"))?;
let doc = s.doc()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad("component.goToMain", "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return Err(bad("component.goToMain", "not an instance")) };
if !doc.contains(component) {
return Err(EngineError::Other("the main component was deleted".into()));
}
super::edit::select_and_page(s, vec![component])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(component) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": component}))
}),
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
spec!("component.restoreMain", "Restore main component", "{id?}", true, |_s, _p| {
Err(EngineError::Disabled("component.restoreMain".into(), "the main component still exists".into()))
}),
spec!("instance.swap", "Swap instance", "{ids?, component}", true, |s, p| {
let c = req_id("instance.swap", p, "component")?;
for i in req_targets("instance.swap", s, p)? {
if let NodeKind::Instance { component, .. } = &mut s.doc_mut()?.get_mut(i)?.kind {
*component = c;
}
}
ok()
}),
spec!("instance.setProperties", "Set instance properties", "{ids?, properties {name: value}} (variant properties switch variants)", true, set_props),
spec!("component.addProperty", "Add component property", "{id? (component or set), name, type: boolean|text|instanceSwap|variant, default, layer? (node bound to it)}", true, add_prop),
spec!("component.list", "List components", "{}", false, |s, _| {
let doc = s.doc()?;
Ok(Value::Array(
doc.nodes()
.filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
.map(|n| json!({"id": n.id, "name": n.name, "type": n.kind.type_name(), "page": doc.page_of(n.id), "instances": instances_of(doc, n.id).len()}))
.collect(),
))
}),
]
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("component.create", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let target = if ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame | NodeKind::Group)) {
ids.first().copied().ok_or_else(|| bad("component.create", "nothing selected"))?
} else {
// Wrap the layers in a frame first (like Figma).
let r = super::structure::specs()
.into_iter()
.find(|x| x.id == "object.frameSelection")
.map(|x| (x.run)(s, &json!({"ids": ids})))
.ok_or_else(|| EngineError::Other("frame selection unavailable".into()))??;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let doc = s.doc_mut()?;
let n = doc.get_mut(fid)?;
n.fills.clear();
fid
};
let doc = s.doc_mut()?;
let n = doc.get_mut(target)?;
let was_group = matches!(n.kind, NodeKind::Group);
n.kind = NodeKind::Component { description: String::new(), props: Default::default() };
if was_group {
n.fills.clear();
n.clip = false;
n.blend = figcraft_color::BlendMode::PassThrough;
}
if ids.len() == 1 && was_group {
// Keep the group's name; frames keep theirs too.
}
s.set_selection(vec![target]);
Ok(json!({"id": target}))
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("component.combineVariants", s, p)?;
let doc = s.doc_mut()?;
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
return Err(bad("component.combineVariants", "select components only"));
}
let ids = z_sorted(doc, &ids);
let top = *ids.last().ok_or_else(|| bad("component.combineVariants", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("component.combineVariants", "page"))?;
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)).map(|n| n.name.split('/').next().unwrap_or("Component").trim().to_string()).unwrap_or_else(|| "Component".into());
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 (k, i) in ids.iter().enumerate() {
doc.reparent(*i, set, None)?;
// Name variants "Property 1=Variant N" unless already in prop=value form.
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) });
}
}
s.set_selection(vec![set]);
Ok(json!({"id": set}))
}
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("component.detach", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for i in ids {
let inst = if matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { i } else { continue };
for d in doc.subtree(inst) {
if let Some(n) = doc.node_mut(d) {
n.source = None;
n.overrides.clear();
}
}
let n = doc.get_mut(inst)?;
n.kind = NodeKind::Frame;
out.push(inst);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
let i = targets(s, p).first().copied().ok_or_else(|| bad("component.pushChanges", "select an instance"))?;
let doc = s.doc_mut()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad("component.pushChanges", "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() };
// Copy every overridden layer's values to its source in the main.
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) {
continue;
}
let Some(mut target) = doc.node(src).cloned() else { continue };
for g in &n.overrides {
match g.as_str() {
"fills" => target.fills = n.fills.clone(),
"strokes" => {
target.strokes = n.strokes.clone();
target.stroke = n.stroke.clone();
}
"effects" => target.effects = n.effects.clone(),
"opacity" => target.opacity = n.opacity,
"visible" => target.visible = n.visible,
"corners" => target.corners = n.corners,
"characters" | "textStyle" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&n.kind, &mut target.kind) {
*b = a.clone();
}
}
_ => {}
}
}
if let Some(slot) = doc.node_mut(src) {
*slot = target;
}
if let Some(m) = doc.node_mut(d) {
m.overrides.clear();
}
}
ok()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("instance.setProperties", s, p)?;
let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad("instance.setProperties", "`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 { continue };
// Variant properties: switch to the matching sibling variant.
let set = doc.node(component).and_then(|c| c.parent).filter(|pp| doc.node(*pp).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })));
let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default();
let mut variant_change = false;
for (k, v) in &props {
if wanted.contains_key(k) {
wanted.insert(k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()));
variant_change = true;
}
}
let n = doc.get_mut(i)?;
if let NodeKind::Instance { component: c, props: ip } = &mut n.kind {
for (k, v) in &props {
ip.insert(k.clone(), v.clone());
}
let _ = c;
}
if variant_change
&& let Some(set) = set
&& let Some(v) = find_variant(doc, set, &wanted)
&& let NodeKind::Instance { component: c, .. } = &mut doc.get_mut(i)?.kind
{
*c = v;
}
}
ok()
}
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addProperty";
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
let name = req_string(cmd, p, "name")?;
let t = req_string(cmd, p, "type")?;
let def = match t.to_ascii_lowercase().as_str() {
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
"text" => PropDef::Text { default: string(p, "default").unwrap_or_default() },
"instanceswap" | "instance_swap" | "instance" => PropDef::InstanceSwap { default: id(p, "default").ok_or_else(|| bad(cmd, "default: component id"))? },
"variant" => PropDef::Variant { options: vec![], default: string(p, "default").unwrap_or_default() },
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant")),
};
let layer = id(p, "layer");
let doc = s.doc_mut()?;
let n = doc.get_mut(target)?;
match &mut n.kind {
NodeKind::Component { props, .. } => {
props.insert(name.clone(), def.clone());
}
NodeKind::ComponentSet { .. } => {
// Variant property on a set: rename every variant with the new property.
let kids = n.children.clone();
let dv = string(p, "default").unwrap_or_else(|| "Default".into());
for c in kids {
let cn = doc.get_mut(c)?;
cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") };
}
return ok();
}
_ => return Err(bad(cmd, "select a component or component set")),
}
if let Some(l) = layer {
let field = match def {
PropDef::Boolean { .. } => "visible",
PropDef::Text { .. } => "characters",
PropDef::InstanceSwap { .. } => "mainComponent",
PropDef::Variant { .. } => "variant",
};
doc.get_mut(l)?.prop_refs.insert(field.into(), name.clone());
}
// Existing instances get the default.
for inst in instances_of(doc, target) {
if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind {
let v = match &def {
PropDef::Boolean { default } => json!(default),
PropDef::Text { default } => json!(default),
PropDef::InstanceSwap { default } => json!(default.0),
PropDef::Variant { default, .. } => json!(default),
};
props.entry(name.clone()).or_insert(v);
}
}
ok()
}

View File

@ -0,0 +1,300 @@
//! Creating nodes.
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::*;
use figcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
("Phone", "iPhone 16", 393.0, 852.0),
("Phone", "iPhone 16 Pro", 402.0, 874.0),
("Phone", "iPhone 16 Pro Max", 440.0, 956.0),
("Phone", "iPhone 16 Plus", 430.0, 932.0),
("Phone", "iPhone SE", 320.0, 568.0),
("Phone", "Android Compact", 412.0, 917.0),
("Phone", "Android Medium", 700.0, 840.0),
("Tablet", "iPad mini 8.3", 744.0, 1133.0),
("Tablet", "iPad Pro 11\"", 834.0, 1194.0),
("Tablet", "iPad Pro 12.9\"", 1024.0, 1366.0),
("Tablet", "Surface Pro 8", 1440.0, 960.0),
("Tablet", "Android Expanded", 1280.0, 800.0),
("Desktop", "MacBook Air", 1280.0, 832.0),
("Desktop", "MacBook Pro 14\"", 1512.0, 982.0),
("Desktop", "MacBook Pro 16\"", 1728.0, 1117.0),
("Desktop", "Desktop", 1440.0, 1024.0),
("Desktop", "Wireframe", 1440.0, 1024.0),
("Desktop", "TV", 1280.0, 720.0),
("Presentation", "Slide 16:9", 1920.0, 1080.0),
("Presentation", "Slide 4:3", 1024.0, 768.0),
("Watch", "Apple Watch Series 10 42mm", 187.0, 223.0),
("Watch", "Apple Watch 41mm", 176.0, 215.0),
("Watch", "Apple Watch 45mm", 198.0, 242.0),
("Paper", "A4", 595.0, 842.0),
("Paper", "A5", 420.0, 595.0),
("Paper", "A6", 297.0, 420.0),
("Paper", "Letter", 612.0, 792.0),
("Paper", "Tabloid", 792.0, 1224.0),
("Social media", "Instagram post", 1080.0, 1350.0),
("Social media", "Instagram story", 1080.0, 1920.0),
("Social media", "Facebook post", 1200.0, 630.0),
("Social media", "X post", 1200.0, 675.0),
("Social media", "LinkedIn post", 1200.0, 1200.0),
("Social media", "YouTube thumbnail", 1280.0, 720.0),
("Social media", "Dribbble shot", 1600.0, 1200.0),
];
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"node.create",
"Create layer",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (parent-relative), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
true,
create
),
spec!("frame.create", "Create frame", "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?}", true, frame_create),
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| create_kind(s, p, "text")),
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(s, p, "vector")),
spec!("image.place", "Place image", "{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)}", true, place_image),
spec!("frame.presets", "List frame presets", "{}", false, |_, _| {
Ok(Value::Array(FRAME_PRESETS.iter().map(|(c, n, w, h)| json!({"category": c, "name": n, "width": w, "height": h})).collect()))
}),
]
}
/// The top-most frame-like (or section) node on the page containing page point `pt`,
/// excluding `exclude` and their descendants.
pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) -> Option<NodeId> {
fn rec(doc: &Document, id: NodeId, pt: Point, exclude: &[NodeId], depth: usize) -> Option<NodeId> {
if depth > 128 || exclude.contains(&id) {
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
return None;
}
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
let inside = doc.world_bounds(id).is_some_and(|b| b.contains(pt)) && {
let inv = doc.world_transform(id).inverse();
n.local_rect().contains(inv * pt)
};
if is_container && !inside {
return None;
}
if !matches!(n.kind, NodeKind::Instance { .. }) {
for c in n.children.iter().rev() {
if let Some(f) = rec(doc, *c, pt, exclude, depth + 1) {
return Some(f);
}
}
}
(is_container && inside && !matches!(n.kind, NodeKind::Instance { .. })).then_some(id)
}
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| rec(doc, *c, pt, exclude, 0))
}
pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
Some(match t.to_ascii_lowercase().as_str() {
"rectangle" | "rect" => (NodeKind::Rectangle, "Rectangle"),
"ellipse" | "circle" | "oval" => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"),
"polygon" | "triangle" => (NodeKind::Polygon { count: 3 }, "Polygon"),
"star" => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"),
"line" => (NodeKind::Line, "Line"),
"arrow" => (NodeKind::Line, "Arrow"),
"vector" | "path" => (NodeKind::Vector { network: VectorNetwork::default() }, "Vector"),
"text" => (NodeKind::Text(TextData::default()), "Text"),
"frame" => (NodeKind::Frame, "Frame"),
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
"group" => (NodeKind::Group, "Group"),
"slice" => (NodeKind::Slice, "Slice"),
_ => return None,
})
}
fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.insert("type".into(), json!(t));
}
create(s, &p)
}
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let doc = s.doc()?;
let pn = doc.get(parent)?;
if !pn.kind.is_container() {
return Err(bad(cmd, format!("{parent} can't contain layers")));
}
let x = num_or(p, "x", 0.0);
let y = num_or(p, "y", 0.0);
let (mut w, mut h) = match base {
"Line" | "Arrow" => (num_or(p, "width", 100.0), 0.0),
"Text" => (num_or(p, "width", 0.0), num_or(p, "height", 0.0)),
"Frame" | "Component" => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box.
let mut offset = (0.0, 0.0);
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
*network = VectorNetwork::from_path(&path);
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
offset = (bb.x0, bb.y0);
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
if w > 0.0 {
td.auto_resize = TextAutoResize::Height;
}
}
let name = string(p, "name").unwrap_or_else(|| {
if let NodeKind::Text(td) = &kind {
let first: String = td.characters.lines().next().unwrap_or("").chars().take(64).collect();
if !first.trim().is_empty() {
return first;
}
}
if matches!(base, "Frame" | "Section" | "Component" | "Group") { s.doc().map(|d| d.next_name(base)).unwrap_or_else(|_| base.to_string()) } else { base.to_string() }
});
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let nid = doc.alloc_id();
let mut node = Node::new(nid, kind, name, w, h);
node.transform = figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
if base == "Arrow" {
node.stroke.cap_end = StrokeCap::LineArrow;
}
if base == "Section" {
node.fills = vec![Paint::solid(Color::WHITE)];
node.strokes = vec![Paint::solid(Color::from_rgb8_const(0xE6, 0xE6, 0xE6))];
node.corners.radii = [2.0; 4];
node.clip = false;
}
if let NodeKind::Text(_) = node.kind
&& let Some((tw, th)) = figcraft_text::auto_size(&node)
{
node.width = tw;
node.height = th;
}
let index = num(p, "index").map(|i| i.max(0.0) as usize);
doc.insert(node, parent, index)?;
if let Some(Value::Object(props)) = get(p, "props") {
super::props::apply(cmd, doc, nid, props)?;
}
if boolean(p, "select").unwrap_or(true) {
ds.selection = vec![nid];
}
Ok(json!({"id": nid}))
}
fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(name) = string(p, "preset") {
let (_, pname, w, h) = FRAME_PRESETS
.iter()
.find(|(_, n, _, _)| n.eq_ignore_ascii_case(&name))
.ok_or_else(|| bad("frame.create", format!("unknown preset `{name}`")))?;
if let Some(o) = q.as_object_mut() {
o.entry("width").or_insert(json!(w));
o.entry("height").or_insert(json!(h));
o.entry("name").or_insert(json!(pname));
}
}
if let Some(o) = q.as_object_mut() {
o.insert("type".into(), json!("frame"));
}
create(s, &q)
}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.place";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
read_path(&path)?
} else {
return Err(bad(cmd, "pass `data` (base64) or `path`"));
};
let mode = match string(p, "mode").unwrap_or_default().to_ascii_lowercase().as_str() {
"fit" => ImageScaleMode::Fit,
"crop" => ImageScaleMode::Crop,
"tile" => ImageScaleMode::Tile,
_ => ImageScaleMode::Fill,
};
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let (iw, ih) = (f64::from(iw), f64::from(ih));
// Fit large images into 1000 px like Figma does when placing.
let k = (1000.0 / iw.max(ih)).min(1.0);
let w = num(p, "width").unwrap_or(iw * k);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
let nid = doc.alloc_id();
let name = string(p, "name").unwrap_or_else(|| "Image".into());
let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
node.transform = figcraft_geom::Affine::translate((num_or(p, "x", 0.0), num_or(p, "y", 0.0)));
node.fills = vec![Paint::image(hash.clone(), mode)];
doc.insert(node, parent, None)?;
ds.selection = vec![nid];
let _ = ok;
Ok(json!({"id": nid, "image": hash, "width": w, "height": h}))
}

View File

@ -0,0 +1,856 @@
//! Edit menu: undo/redo, clipboard, duplicate/delete, selection commands, find, properties.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
spec!("edit.redo", "Redo", "{}", false, |s, _| Ok(json!({"redone": s.redo()?}))),
spec!("edit.delete", "Delete", "{ids?}", true, delete),
spec!("edit.duplicate", "Duplicate", "{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}", true, duplicate),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
spec!("edit.pasteProperties", "Paste properties", "{ids?}", true, paste_props),
spec!("edit.findReplace", "Find and replace", "{find, replace? (text layers), layerNames? (bool), select? (default true)}", true, find_replace),
spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
let v = copy_props_of(s.doc()?, id);
s.clipboard.props = Some(v);
ok()
}),
spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| {
let ids = match get(p, "ids") {
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
_ => id(p, "id").into_iter().collect(),
};
select_and_page(s, ids)?;
Ok(json!({"selection": s.selection()}))
}),
spec!("select.add", "Add to selection", "{ids}", false, |s, p| {
let mut sel = s.selection();
for i in targets_param(p) {
if !sel.contains(&i) {
sel.push(i);
}
}
s.set_selection(sel);
Ok(json!({"selection": s.selection()}))
}),
spec!("select.remove", "Remove from selection", "{ids}", false, |s, p| {
let rm = targets_param(p);
let sel: Vec<NodeId> = s.selection().into_iter().filter(|i| !rm.contains(i)).collect();
s.set_selection(sel);
Ok(json!({"selection": s.selection()}))
}),
spec!("select.all", "Select all", "{}", false, select_all),
spec!("select.none", "Select none", "{}", false, |s, _| {
s.set_selection(vec![]);
if let Ok(ds) = s.ds_mut() {
ds.text_edit = None;
ds.vector_edit = None;
}
ok()
}),
spec!("select.inverse", "Select inverse", "{}", false, select_inverse),
spec!("select.children", "Select children", "{}", false, select_children),
spec!("select.parent", "Select parent", "{}", false, select_parent),
spec!("select.next", "Select next sibling", "{}", false, |s, _| select_sibling(s, 1)),
spec!("select.previous", "Select previous sibling", "{}", false, |s, _| select_sibling(s, -1)),
spec!("select.matching", "Select matching layers", "{}", false, select_matching),
spec!("select.sameFill", "Select same fill", "{}", false, |s, _| select_same(s, |a, b| a.fills == b.fills && !a.fills.is_empty())),
spec!("select.sameStroke", "Select same stroke", "{}", false, |s, _| select_same(s, |a, b| a.strokes == b.strokes && a.stroke == b.stroke && !a.strokes.is_empty())),
spec!("select.sameEffect", "Select same effect", "{}", false, |s, _| select_same(s, |a, b| a.effects == b.effects && !a.effects.is_empty())),
spec!("select.sameText", "Select same text properties", "{}", false, |s, _| {
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style == y.style))
}),
spec!("select.sameFont", "Select same font", "{}", false, |s, _| {
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style.font_family == y.style.font_family && x.style.font_style == y.style.font_style))
}),
spec!("select.sameInstance", "Select same instance", "{}", false, |s, _| {
select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x == y))
}),
spec!("select.sameProperties", "Select same properties", "{}", false, |s, _| {
select_same(s, |a, b| a.kind.type_name() == b.kind.type_name() && a.fills == b.fills && a.strokes == b.strokes && a.effects == b.effects && a.corners == b.corners)
}),
spec!("select.byName", "Select layers by name", "{query, page? (default current; \"all\" for every page)}", false, |s, p| {
let q = req_string("select.byName", p, "query")?;
let page = if string(p, "page").as_deref() == Some("all") { None } else { Some(s.page()?) };
let ids = s.doc()?.find_by_name(&q, page);
select_and_page(s, ids.clone())?;
Ok(json!({"selection": ids}))
}),
spec!("transform.nudge", "Nudge", "{dx, dy, big? (use the big nudge amount)}", true, nudge),
spec!("object.rename", "Rename", "{id?, name}", true, |s, p| {
let name = req_string("object.rename", p, "name")?;
let ids = req_targets("object.rename", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.name = name.clone();
}
ok()
}),
spec!("object.batchRename", "Batch rename", "{ids?, find?, replace?, name? (pattern: $& = current name, $N or $n = number asc/desc)}", true, batch_rename),
spec!("object.toggleVisible", "Show/Hide selection", "{ids?, visible?}", true, |s, p| toggle(s, p, "visible")),
spec!("object.toggleLock", "Lock/Unlock selection", "{ids?, locked?}", true, |s, p| toggle(s, p, "locked")),
spec!("object.hideOthers", "Hide other layers", "{}", true, hide_others),
spec!("object.removeFill", "Remove fill", "{ids?}", true, |s, p| {
for i in req_targets("object.removeFill", s, p)? {
s.doc_mut()?.get_mut(i)?.fills.clear();
}
ok()
}),
spec!("object.removeStroke", "Remove stroke", "{ids?}", true, |s, p| {
for i in req_targets("object.removeStroke", s, p)? {
s.doc_mut()?.get_mut(i)?.strokes.clear();
}
ok()
}),
spec!("object.swapFillStroke", "Swap fill and stroke", "{ids?}", true, |s, p| {
for i in req_targets("object.swapFillStroke", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
std::mem::swap(&mut n.fills, &mut n.strokes);
}
ok()
}),
spec!("layers.collapseAll", "Collapse layers", "{}", false, |s, _| {
s.request_ui("layers.collapseAll", json!({}));
ok()
}),
spec!("node.set", "Set properties", super::props::KEYS, true, set),
spec!("node.get", "Get layer", "{id}", false, |s, p| {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent),
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
let i = req_id("node.reorder", p, "id")?;
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
s.doc_mut()?.reorder(i, idx)?;
ok()
}),
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
spec!("effect.add", "Add effect", "{ids?, type? (dropShadow|innerShadow|layerBlur|backgroundBlur|noise|texture), effect? {..}}", true, effect_add),
spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| {
let i = req_num("effect.remove", p, "index")? as usize;
for id in req_targets("effect.remove", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if i < n.effects.len() {
n.effects.remove(i);
}
}
ok()
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_num("effect.set", p, "index")? as usize;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if let Some(slot) = n.effects.get_mut(i) {
*slot = e.clone();
}
}
ok()
}),
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
for id in req_targets("grid.add", s, p)? {
s.doc_mut()?.get_mut(id)?.grids.push(g.clone());
}
ok()
}),
spec!("grid.remove", "Remove layout grid", "{ids?, index}", true, |s, p| {
let i = req_num("grid.remove", p, "index")? as usize;
for id in req_targets("grid.remove", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if i < n.grids.len() {
n.grids.remove(i);
}
}
ok()
}),
spec!("export.add", "Add export setting", "{ids?, setting? {size:{by:scale,value:2}, suffix, format: png|jpg|svg|pdf|webp}}", true, |s, p| {
let e: ExportSetting = parse("export.add", p, "setting")?.unwrap_or_default();
for id in req_targets("export.add", s, p)? {
s.doc_mut()?.get_mut(id)?.exports.push(e.clone());
}
ok()
}),
spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| {
let i = req_num("export.remove", p, "index")? as usize;
for id in req_targets("export.remove", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if i < n.exports.len() {
n.exports.remove(i);
}
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<(String, Value)> = match get(p, "commands") {
Some(Value::Array(a)) => a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect(),
_ => return Err(bad("command.batch", "`commands` must be an array")),
};
Ok(Value::Array(s.batch(&label, &cmds)?))
}),
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
let Some((id, p)) = s.last_command.clone() else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())) };
let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?;
(spec.run)(s, &p)
}),
]
}
fn targets_param(p: &Value) -> Vec<NodeId> {
match get(p, "ids") {
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
_ => id(p, "id").into_iter().collect(),
}
}
/// Select `ids`, switching to their page if they're elsewhere.
pub fn select_and_page(s: &mut Session, ids: Vec<NodeId>) -> Result<()> {
let doc = s.doc()?;
for i in &ids {
if !doc.contains(*i) {
return Err(EngineError::NoNode(*i));
}
}
if let Some(first) = ids.first()
&& let Some(pg) = doc.page_of(*first)
&& pg != s.page()?
{
s.ds_mut()?.page = pg;
}
s.set_selection(ids);
Ok(())
}
/// Top-level selection: drop ids whose ancestor is also selected.
pub fn roots(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let set: BTreeSet<NodeId> = ids.iter().copied().collect();
ids.iter().copied().filter(|i| !doc.ancestors(*i).iter().any(|a| set.contains(a))).collect()
}
/// Ids sorted in document (z) order.
pub fn z_sorted(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)) else { return ids.to_vec() };
let order: BTreeMap<NodeId, usize> = doc.subtree(page).into_iter().enumerate().map(|(i, id)| (id, i)).collect();
let mut v = ids.to_vec();
v.sort_by_key(|i| order.get(i).copied().unwrap_or(usize::MAX));
v
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.delete", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let mut parents = Vec::new();
for i in &ids {
if doc.node(*i).is_some_and(Node::is_page) {
continue;
}
if let Some(pn) = doc.node(*i).and_then(|n| n.parent) {
parents.push(pn);
}
doc.remove(*i)?;
}
// Select the parent frame like Figma? Figma clears the selection.
s.set_selection(vec![]);
Ok(json!({"deleted": ids}))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.duplicate", s, p)?;
let rename = s.prefs.rename_duplicates;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let mut out = Vec::new();
for i in ids {
let n = doc.get(i)?;
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
let idx = doc.index_in_parent(i).map(|x| x + 1);
let w = n.width;
let nid = doc.duplicate_into(i, parent, idx)?;
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
(None, Some(dy)) => (0.0, dy),
// Top-level frames duplicate to the right with a gap, everything else in place.
_ if top_frame => (w + 100.0, 0.0),
_ => (0.0, 0.0),
};
let nn = doc.get_mut(nid)?;
nn.transform = Affine::translate((dx, dy)) * nn.transform;
if rename {
nn.name = bump_name(&nn.name);
}
out.push(nid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn bump_name(n: &str) -> String {
if let Some((base, num)) = n.rsplit_once(' ')
&& let Ok(k) = num.parse::<u64>()
{
return format!("{base} {}", k + 1);
}
format!("{n} 2")
}
/// The copied form of the selection.
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
let ds = s.ds()?;
let doc = &ds.doc;
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<Node> = 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);
let uid = ds.uid;
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.source_doc = uid;
Ok(())
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, &ids)?;
delete(s, &json!({"ids": ids}))
}
/// 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<Vec<NodeId>> {
let clip = s.clipboard.clone();
if clip.nodes.is_empty() {
return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into()));
}
let doc = s.doc_mut()?;
for (k, v) in &clip.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
let parent_world = doc.world_transform(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();
for (root, nodes) in &clip.nodes {
let mut map = BTreeMap::new();
for n in nodes {
map.insert(n.id, doc.alloc_id());
}
let mut nodes = nodes.clone();
if let Some(r) = nodes.first_mut() {
r.transform = parent_world.inverse() * shift * r.transform;
}
let nid = doc.paste_nodes(&nodes, &map, *root, parent, None)?;
out.push(nid);
}
Ok(out)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
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 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 { .. })))
});
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<Value> {
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<Value> {
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["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<Value> {
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<Value> {
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 select_all(s: &mut Session, _p: &Value) -> Result<Value> {
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<NodeId> = 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}))
}
fn select_inverse(s: &mut Session, _p: &Value) -> Result<Value> {
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<NodeId> = doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| !n.locked)).collect();
s.set_selection(ids.clone());
Ok(json!({"selection": ids}))
}
fn select_children(s: &mut Session, _p: &Value) -> Result<Value> {
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<Value> {
let doc = s.doc()?;
let mut ids: Vec<NodeId> = 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<Value> {
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<Value> {
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<NodeId> = 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<Value> {
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<NodeId> = 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}))
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let big = boolean(p, "big").unwrap_or(false);
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
let dx = num_or(p, "dx", 0.0) * k;
let dy = num_or(p, "dy", 0.0) * k;
let ids = req_targets("transform.nudge", s, p)?;
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
let n = doc.get_mut(i)?;
n.transform = Affine::translate((dx, dy)) * n.transform;
}
ok()
}
fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
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<Value> {
let sel = s.selection();
let page = s.page()?;
let doc = s.doc_mut()?;
let keep: BTreeSet<NodeId> = sel.iter().flat_map(|i| {
let mut v = doc.ancestors(*i);
v.push(*i);
v
}).collect();
let tops: Vec<NodeId> = 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<Value> {
let ids = req_targets("object.batchRename", s, p)?;
let find = string(p, "find");
let replace = string(p, "replace").unwrap_or_default();
let pattern = string(p, "name");
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &ids);
let n = ids.len();
for (k, i) in ids.iter().enumerate() {
let node = doc.get_mut(*i)?;
let mut name = node.name.clone();
if let Some(f) = &find
&& !f.is_empty()
{
name = name.replace(f.as_str(), &replace);
}
if let Some(pat) = &pattern {
name = pat.replace("$&", &name).replace("$N", &(k + 1).to_string()).replace("$n", &(n - k).to_string());
}
node.name = name;
}
ok()
}
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
let find = req_string("edit.findReplace", p, "find")?;
if find.is_empty() {
return Err(bad("edit.findReplace", "`find` is empty"));
}
let replace = string(p, "replace");
let names = boolean(p, "layerNames").unwrap_or(false);
let page = s.page()?;
let doc = s.doc_mut()?;
let mut hits = Vec::new();
let lower = find.to_lowercase();
for i in doc.subtree(page) {
let Some(n) = doc.node(i) else { continue };
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
let name_hit = names && n.name.to_lowercase().contains(&lower);
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 {
n.name = n.name.replace(find.as_str(), r);
}
if let NodeKind::Text(t) = &mut n.kind {
// Replace occurrence by occurrence so styles shift correctly.
let mut guard = 0;
while let Some(pos) = t.characters.find(find.as_str()) {
let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0);
let len = find.chars().count();
t.replace_range(start, start + len, r);
guard += 1;
if guard > 10_000 || r.contains(find.as_str()) {
break;
}
}
}
}
}
if boolean(p, "select").unwrap_or(true) {
s.set_selection(hits.clone());
}
Ok(json!({"matches": hits}))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
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"));
}
let doc = s.doc_mut()?;
for i in ids {
super::props::apply("node.set", doc, i, &props)?;
}
ok()
}
fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
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));
for (k, i) in ids.iter().enumerate() {
doc.reparent(*i, parent, index.map(|x| x + k))?;
}
ok()
}
fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Value> {
let cmd = if fills { "fill" } else { "stroke" };
let ids = req_targets(cmd, s, p)?;
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 = num(p, "index").map(|x| x as usize).unwrap_or(list.len().saturating_sub(1));
if idx < list.len() {
list.remove(idx);
}
}
}
ok()
}
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.set" } else { "stroke.set" };
let ids = req_targets(cmd, s, p)?;
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
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}"))) };
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;
}
}
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;
}
if let Some(v) = boolean(p, "visible") {
slot.visible = v;
}
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
if fills {
n.styles.fill = None;
} else {
n.styles.stroke = None;
}
}
ok()
}
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
let e: Effect = match get(p, "effect") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
None => match string(p, "type").unwrap_or_else(|| "dropShadow".into()).to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() {
"dropshadow" => Effect::drop_shadow(),
"innershadow" => Effect::inner_shadow(),
"layerblur" | "blur" => Effect::layer_blur(),
"backgroundblur" => Effect::background_blur(),
"noise" => Effect { kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) }, visible: true },
"texture" => Effect { kind: EffectKind::Texture { size: 0.5, radius: 4.0 }, visible: true },
other => return Err(bad("effect.add", format!("unknown effect type `{other}`"))),
},
};
for i in req_targets("effect.add", s, p)? {
s.doc_mut()?.get_mut(i)?.effects.push(e.clone());
}
ok()
}

View File

@ -0,0 +1,363 @@
//! File menu: documents, save/open, import/export, versions.
use std::sync::Arc;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{DocState, EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("file.new", "New design file", "{name?}", false, |s, p| {
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
s.docs.push(DocState::new(Document::new(name), None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!("file.open", "Open", "{path} (.figcraft, .json, .svg, .png, .jpg …)", false, open),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!("file.export", "Export", "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1), path? (else returns base64 `data`)}", false, export),
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
}
export(s, &q)
}),
spec!("file.import", "Import", "{path | data (base64) + name, x?, y?} SVG becomes vectors, images become image layers, .figcraft pages are appended", true, import),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.rename", "Rename file", "{name}", true, |s, p| {
let name = req_string("file.rename", p, "name")?;
s.doc_mut()?.name = name;
ok()
}),
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
let ds = s.ds()?;
let mut d = (*ds.doc).clone();
d.name = format!("{} (Copy)", d.name);
s.docs.push(DocState::new(d, None));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
let (doc, _) = load(&path)?;
let i = s.active;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(doc, Some(path));
}
ok()
}),
spec!("file.saveVersion", "Save to version history", "{name?, description?}", false, |s, p| {
let name = string(p, "name").unwrap_or_else(|| format!("Version {}", s.ds().map(|d| versions(d).len() + 1).unwrap_or(1)));
let ds = s.ds_mut()?;
let snap = ds.doc.clone();
push_version(ds, name, string(p, "description").unwrap_or_default(), snap);
ok()
}),
spec!("file.versionHistory", "Show version history", "{}", false, |s, _| {
let ds = s.ds()?;
let v: Vec<Value> = versions(ds).iter().enumerate().map(|(i, v)| json!({"index": i, "name": v.0, "description": v.1, "time": v.2})).collect();
s.request_ui("panel.versionHistory", json!({}));
Ok(Value::Array(v))
}),
spec!("version.restore", "Restore version", "{index}", true, |s, p| {
let i = req_num("version.restore", p, "index")? as usize;
let ds = s.ds_mut()?;
let Some((_, _, _, doc)) = version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).and_then(|v| v.get(i)).cloned() else {
return Err(bad("version.restore", "no such version"));
};
ds.doc = doc;
if !ds.doc.pages.contains(&ds.page) {
ds.page = ds.doc.first_page().unwrap_or(ds.page);
}
ok()
}),
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
let i = req_num("doc.switch", p, "index")? as usize;
if i >= s.docs.len() {
return Err(bad("doc.switch", "no such document"));
}
s.active = i;
ok()
}),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(s.docs.iter().enumerate().map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})).collect()))
}),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
}),
]
}
type Version = (String, String, u64, Arc<Document>);
fn version_list() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>> {
static V: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>>> = std::sync::OnceLock::new();
V.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn now_secs() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
fn push_version(ds: &DocState, name: String, desc: String, doc: Arc<Document>) {
version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(ds.uid).or_default().push((name, desc, now_secs(), doc));
}
pub fn versions(ds: &DocState) -> Vec<(String, String, u64)> {
version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).map(|v| v.iter().map(|(a, b, c, _)| (a.clone(), b.clone(), *c)).collect()).unwrap_or_default()
}
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn read(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
fn write(path: &str, bytes: &[u8]) -> Result<()> {
let tmp = format!("{path}.tmp-figcraft");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
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(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
fn file_stem(path: &str) -> String {
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
}
/// Load a document from any supported file.
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
let bytes = read(path)?;
load_bytes(&bytes, path)
}
pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String>)> {
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)?;
figcraft_svg::import_svg(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)).map_err(|e| EngineError::Other(e.to_string()))?;
Ok((doc, vec![]))
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
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, vec![]))
}
_ => {
let (mut doc, issues) = figcraft_format::load_bytes(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if doc.name.is_empty() {
doc.name = file_stem(name_hint);
}
Ok((doc, issues))
}
}
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
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;
return Ok(json!({"index": i}));
}
let (doc, issues) = load(&path)?;
let native = matches!(ext(&path).as_str(), "figcraft" | "json");
s.docs.push(DocState::new(doc, native.then_some(path)));
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active, "repairs": issues}))
}
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
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()
}
fn save_to(s: &mut Session, path: String) -> Result<Value> {
let ds = s.ds_mut()?;
let mut doc = (*ds.doc).clone();
doc.current_page = Some(ds.page);
let thumb = thumbnail(&doc);
let bytes = figcraft_format::save_bytes(&doc, thumb.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?;
write(&path, &bytes)?;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, path);
}
match s.ds()?.path.clone() {
Some(path) => save_to(s, 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<Value> {
match string(p, "path") {
Some(mut path) => {
if ext(&path).is_empty() {
path.push_str(".figcraft");
}
save_to(s, path)
}
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<Vec<u8>> {
if ids.is_empty() {
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
}
match format {
ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default()).map(String::into_bytes).ok_or_else(|| EngineError::Other("nothing to export".into())),
ExportFormat::Pdf => Err(EngineError::Disabled("file.export".into(), "PDF export is not available yet".into())),
_ => {
let pix = if ids.len() == 1 {
ids.first().and_then(|i| figcraft_render::render_node(doc, *i, scale))
} else {
let b = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b));
b.and_then(|b| {
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 mut pix = figcraft_render::Pixmap::new(view.width.clamp(1, 16384), view.height.clamp(1, 16384))?;
let ordered = super::edit::z_sorted(doc, ids);
figcraft_render::render_nodes_into(doc, &ordered, &view, &mut pix);
Some(pix)
})
};
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
match format {
ExportFormat::Jpg => figcraft_render::encode_jpeg(&pix, figcraft_color::Color::WHITE, 90),
ExportFormat::Webp => figcraft_render::encode_webp(&pix),
_ => figcraft_render::encode_png(&pix),
}
.map_err(EngineError::Other)
}
}
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc()?;
let mut ids = targets(s, p);
if ids.is_empty() {
ids = doc.get(s.page()?)?.children.clone();
}
let format = string(p, "format").and_then(|f| ExportFormat::from_ext(&f)).or_else(|| string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp)))).unwrap_or_default();
let scale = num_or(p, "scale", 1.0).clamp(0.01, 64.0);
let bytes = export_bytes(doc, &ids, format, scale)?;
match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()}))
}
None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
}
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, name) = if let Some(path) = string(p, "path") {
(read(&path)?, path)
} 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 id = figcraft_svg::import_svg(doc, &bytes, page, x.unwrap_or(center.0), y.unwrap_or(center.1), &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?;
s.set_selection(vec![id]);
Ok(json!({"id": id}))
}
"figcraft" | "json" => {
let (other, _) = figcraft_format::load_bytes(&bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let doc = s.doc_mut()?;
for (k, v) in &other.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
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);
for c in pn.children.clone() {
let nodes: Vec<Node> = other.subtree(c).into_iter().filter_map(|i| other.node(i).cloned()).collect();
let mut map = std::collections::BTreeMap::new();
for n in &nodes {
map.insert(n.id, doc.alloc_id());
}
doc.paste_nodes(&nodes, &map, c, np, None)?;
}
new_pages.push(np);
}
Ok(json!({"pages": new_pages}))
}
_ => {
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)}))
}
}
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
if ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
s.request_ui("dialog.unsaved", json!({"index": i}));
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
}
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
ok()
}

View File

@ -0,0 +1,95 @@
//! The command registry. Each area module exposes `specs()`; [`all_commands`] collects them once.
pub mod arrange;
pub mod component;
pub mod create;
pub mod edit;
pub mod file;
pub mod page;
pub mod props;
pub mod proto;
pub mod structure;
pub mod styles;
pub mod text;
pub mod view;
use std::collections::HashMap;
use std::sync::OnceLock;
use serde_json::Value;
use crate::{Result, Session};
pub type RunFn = fn(&mut Session, &Value) -> Result<Value>;
/// A registered command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Parameter documentation for agents (`list_commands`).
pub params: &'static str,
/// Whether the command edits the document (an undo step).
pub history: bool,
pub run: RunFn,
}
impl CommandSpec {
pub fn menu(&self) -> Option<&'static str> {
crate::catalog::MENU.iter().find(|m| m.id == self.id).map(|m| m.menu)
}
pub fn shortcut(&self) -> Option<&'static str> {
crate::catalog::shortcut(self.id)
}
}
/// Define a command spec: `spec!(id, label, params_doc, history, run)`.
#[macro_export]
macro_rules! spec {
($id:expr, $label:expr, $params:expr, $hist:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, params: $params, history: $hist, run: $run }
};
}
struct Registry {
list: Vec<CommandSpec>,
index: HashMap<&'static str, usize>,
}
fn registry() -> &'static Registry {
static R: OnceLock<Registry> = OnceLock::new();
R.get_or_init(|| {
let mut list = Vec::new();
list.extend(create::specs());
list.extend(edit::specs());
list.extend(arrange::specs());
list.extend(structure::specs());
list.extend(file::specs());
list.extend(page::specs());
list.extend(view::specs());
list.extend(text::specs());
list.extend(styles::specs());
list.extend(component::specs());
list.extend(proto::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {
log::warn!("duplicate command id {}", s.id);
}
}
Registry { list, index }
})
}
pub fn all_commands() -> &'static [CommandSpec] {
&registry().list
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let r = registry();
r.index.get(id).and_then(|i| r.list.get(*i))
}
/// `{"ok": true}` shorthand.
pub fn ok() -> Result<Value> {
Ok(serde_json::json!({"ok": true}))
}

View File

@ -0,0 +1,123 @@
//! Pages.
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?}", true, |s, p| {
let doc = s.doc_mut()?;
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
let id = doc.add_page(&name);
let ds = s.ds_mut()?;
ds.page = id;
ds.selection.clear();
ds.zoom_to_fit();
Ok(json!({"id": id}))
}),
spec!("page.select", "Go to page", "{id | index}", false, |s, p| {
let doc = s.doc()?;
let id = match id(p, "id") {
Some(i) => i,
None => {
let k = req_num("page.select", p, "index")? as usize;
*doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))?
}
};
if !doc.pages.contains(&id) {
return Err(bad("page.select", format!("{id} is not a page")));
}
let ds = s.ds_mut()?;
ds.page = id;
ds.selection.clear();
ds.text_edit = None;
ds.zoom_to_fit();
ok()
}),
spec!("page.rename", "Rename page", "{id?, name}", true, |s, p| {
let name = req_string("page.rename", p, "name")?;
let id = id(p, "id").unwrap_or(s.page()?);
s.doc_mut()?.get_mut(id)?.name = name;
ok()
}),
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let doc = s.doc_mut()?;
doc.remove(id)?;
let first = doc.first_page();
let ds = s.ds_mut()?;
if ds.page == id
&& let Some(f) = first
{
ds.page = f;
ds.selection.clear();
}
ok()
}),
spec!("page.duplicate", "Duplicate page", "{id?}", true, |s, p| {
let src = id(p, "id").unwrap_or(s.page()?);
let doc = s.doc_mut()?;
let name = format!("{} (Copy)", doc.get(src)?.name);
let np = doc.add_page(&name);
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
{
*b = background;
}
for c in doc.get(src)?.children.clone() {
doc.duplicate_into(c, np, None)?;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
doc.pages.retain(|x| *x != np);
doc.pages.insert(si + 1, np);
}
s.ds_mut()?.page = np;
Ok(json!({"id": np}))
}),
spec!("page.move", "Move page", "{id?, index}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
let doc = s.doc_mut()?;
doc.pages.retain(|x| *x != id);
let k = k.min(doc.pages.len());
doc.pages.insert(k, id);
ok()
}),
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
let id = id(p, "id").unwrap_or(s.page()?);
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
*background = c;
}
ok()
}),
spec!("page.list", "List pages", "{}", false, |s, _| {
let doc = s.doc()?;
let cur = s.page()?;
Ok(Value::Array(doc.pages.iter().filter_map(|p| doc.node(*p)).map(|n| json!({"id": n.id, "name": n.name, "current": n.id == cur, "children": n.children.len()})).collect()))
}),
spec!("guide.add", "Add guide", "{vertical (bool), offset, page?}", true, |s, p| {
let id = id(p, "page").unwrap_or(s.page()?);
let g = Guide { vertical: boolean(p, "vertical").unwrap_or(true), offset: req_num("guide.add", p, "offset")? };
if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
guides.push(g);
}
ok()
}),
spec!("guide.remove", "Remove guide", "{index, page?}", true, |s, p| {
let id = id(p, "page").unwrap_or(s.page()?);
let k = req_num("guide.remove", p, "index")? as usize;
if let NodeKind::Page { guides, .. } = &mut s.doc_mut()?.get_mut(id)?.kind
&& k < guides.len()
{
guides.remove(k);
}
ok()
}),
]
}

View File

@ -0,0 +1,598 @@
//! Property editing: `node.set` and the shorthand keys every surface uses.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, Point};
use serde_json::{Map, Value, json};
use crate::params::*;
use crate::{Result, bad};
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
strokeCap / startCap / endCap, strokeJoin, dashes [..], cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
let norm: String = {
let mut out = String::new();
let mut up = false;
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
continue;
}
if i == 0 {
out.extend(ch.to_lowercase());
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
out
};
serde_json::from_value(Value::String(norm)).ok()
}
fn sizing(v: &Value) -> Option<Sizing> {
match v.as_str()?.to_ascii_lowercase().as_str() {
"fixed" => Some(Sizing::Fixed),
"hug" | "auto" | "hug contents" => Some(Sizing::Hug),
"fill" | "fill container" | "stretch" => Some(Sizing::Fill),
_ => None,
}
}
fn line_height(v: &Value) -> Option<LineHeight> {
match v {
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LineHeight::Pixels),
}
}
fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
match v {
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LetterSpacing::Pixels),
}
}
fn four(v: &Value) -> Option<[f64; 4]> {
match v {
Value::Array(a) => {
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
match f.as_slice() {
[x] => Some([*x; 4]),
[a, b] => Some([*a, *b, *a, *b]),
[a, b, c, d] => Some([*a, *b, *c, *d]),
_ => None,
}
}
other => f64_of(other).map(|x| [x; 4]),
}
}
fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> {
let id = n.id;
n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text")))
}
fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
_ => 1,
};
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| order(k));
for (k, v) in keys {
let k = k.as_str();
match k {
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let n = doc.get_mut(id)?;
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
let c = n.transform.as_coeffs();
// Figma's X/Y are the node's own origin (its rotated top-left) for rotated nodes.
let (mut tx, mut ty) = (c[4], c[5]);
let _ = b;
if k == "x" {
tx = val;
} else {
ty = val;
}
n.transform = Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
}
"width" | "height" | "w" | "h" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let (ow, oh, keep) = {
let n = doc.get(id)?;
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
};
let _ = keep;
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if k.starts_with('w') {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if k.starts_with('w') {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
"name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?,
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
"opacity" => {
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
let o = if o > 1.0 { o / 100.0 } else { o };
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
}
"blend" | "blendMode" => {
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
}
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
"strokeSides" => doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) },
"strokeCap" => {
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
let n = doc.get_mut(id)?;
n.stroke.cap_start = c;
n.stroke.cap_end = c;
}
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
"dashes" => {
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
doc.get_mut(id)?.stroke.dashes = d;
}
"cornerRadius" => {
let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?;
doc.get_mut(id)?.corners.radii = r.map(|x| x.max(0.0));
}
"topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => {
let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0);
let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) {
*slot = r;
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"layout" | "layoutMode" => {
let n = doc.get_mut(id)?;
if !n.kind.is_frame_like() {
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => n.layout = None,
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")),
};
ensure_layout(n).mode = mode;
}
Value::Object(_) => n.layout = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("layout: {e}")))?),
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
}
}
"gap" | "itemSpacing" => ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?,
"crossGap" | "counterAxisSpacing" => ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))?,
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
*slot = val;
}
}
"paddingHorizontal" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
l.padding[1] = val;
l.padding[3] = val;
}
"paddingVertical" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
l.padding[0] = val;
l.padding[2] = val;
}
"mainAlign" | "primaryAxisAlignItems" => ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))?,
"crossAlign" | "counterAxisAlignItems" => ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?,
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"gridColumns" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
"gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, true)?;
}
"sizingV" | "layoutSizingVertical" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, false)?;
}
"absolute" => doc.get_mut(id)?.child_layout.absolute = v.as_bool().unwrap_or(false),
"minWidth" => doc.get_mut(id)?.child_layout.min_w = f64_of(v),
"maxWidth" => doc.get_mut(id)?.child_layout.max_w = f64_of(v),
"minHeight" => doc.get_mut(id)?.child_layout.min_h = f64_of(v),
"maxHeight" => doc.get_mut(id)?.child_layout.max_h = f64_of(v),
"constraints" => {
let n = doc.get_mut(id)?;
if let Some(h) = v.get("horizontal") {
n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?;
}
if let Some(vv) = v.get("vertical") {
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
}
}
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("grids: {e}")))?,
"exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))?,
"interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?,
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
"characters" | "text" => {
let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?;
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &s);
n.overrides.insert("characters".into());
}
"fontFamily" | "fontStyle" | "fontWeight" | "fontSize" | "lineHeight" | "letterSpacing" | "textDecoration" | "textCase" | "hyperlink" | "textFills" => {
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let (start, end) = range_of(props, t.char_count());
apply_text_style(cmd, t, start, end, k, v)?;
}
"textAlign" | "textAlignHorizontal" => text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))?,
"textAlignVertical" => text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))?,
"textAutoResize" => text_mut(cmd, doc.get_mut(id)?)?.auto_resize = enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?,
"paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0),
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
"listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
"start" | "end" => {}
"pointCount" => {
let c = f64_of(v).unwrap_or(5.0).clamp(3.0, 120.0) as u32;
match &mut doc.get_mut(id)?.kind {
NodeKind::Polygon { count } | NodeKind::Star { count, .. } => *count = c,
_ => return Err(bad(cmd, "pointCount applies to polygons and stars")),
}
}
"innerRadius" | "ratio" => {
let r = f64_of(v).unwrap_or(0.38);
let r = if r > 1.0 { r / 100.0 } else { r };
match &mut doc.get_mut(id)?.kind {
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
_ => return Err(bad(cmd, "innerRadius applies to stars")),
}
}
"arcStart" | "arcSweep" | "arcInner" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Ellipse { start, sweep, inner } => match k {
"arcStart" => *start = val.to_radians(),
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
},
_ => return Err(bad(cmd, "arc applies to ellipses")),
}
}
"description" => match &mut doc.get_mut(id)?.kind {
NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(),
_ => return Err(bad(cmd, "description applies to components")),
},
"pluginData" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (pk, pv) in o {
if pv.is_null() {
n.plugin_data.remove(pk);
} else {
n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string()));
}
}
}
}
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => {
let sid = string_val(v);
crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?;
}
"boundVariables" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (field, var) in o {
match var.as_str() {
Some(vid) => {
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
}
}
}
}
}
"variableModes" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (coll, mode) in o {
match mode.as_str() {
Some(m) => {
n.var_modes.insert(coll.clone(), m.to_string());
}
None => {
n.var_modes.remove(coll);
}
}
}
}
}
"booleanOperation" => match &mut doc.get_mut(id)?.kind {
NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?,
_ => return Err(bad(cmd, "not a boolean group")),
},
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);
}
}
"background" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Page { background, .. } => *background = c,
_ => return Err(bad(cmd, "background applies to pages")),
}
}
"componentProperties" => {
if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind
&& let Some(o) = v.as_object()
{
for (pk, pv) in o {
p.insert(pk.clone(), pv.clone());
}
}
}
other => unknown.push(other.to_string()),
}
}
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden.
let n = doc.get_mut(id)?;
if n.source.is_some() {
for k in props.keys() {
n.overrides.insert(override_group(k).to_string());
}
}
Ok(())
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
"name" => "name",
"characters" | "text" => "characters",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => "textStyle",
_ => "other",
}
}
fn string_val(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn range_of(props: &Map<String, Value>, len: usize) -> (usize, usize) {
let s = props.get("start").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(0);
let e = props.get("end").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(len);
(s.min(len), e.min(len))
}
/// Set one text style property on chars `start..end`.
pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k: &str, v: &Value) -> Result<()> {
match k {
"fontFamily" => {
let fam = string_val(v).ok_or_else(|| bad(cmd, "`fontFamily` must be a string"))?;
// Keep the style if the new family has it, else the nearest.
let styles = figcraft_text::family_styles(&fam);
t.restyle(start, end, |s| {
s.font_family = fam.clone();
if !styles.is_empty() && !styles.contains(&s.font_style) {
let want = s.weight();
if let Some(best) = styles.iter().min_by_key(|st| (i32::from(style_weight(st)) - i32::from(want)).abs()) {
s.font_style = best.clone();
}
}
});
}
"fontStyle" => {
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
t.restyle(start, end, |s| s.font_style = st.clone());
}
"fontWeight" => {
let w = f64_of(v).ok_or_else(|| bad(cmd, "`fontWeight` must be a number"))?;
let name = weight_name(w as u16);
t.restyle(start, end, |s| {
s.font_style = if s.italic() { format!("{name} Italic").replace("Regular Italic", "Italic") } else { name.to_string() }
});
}
"fontSize" => {
let sz = f64_of(v).ok_or_else(|| bad(cmd, "`fontSize` must be a number"))?.clamp(1.0, 5000.0);
t.restyle(start, end, |s| s.font_size = sz);
}
"lineHeight" => {
let lh = line_height(v).ok_or_else(|| bad(cmd, "lineHeight: auto | px | \"150%\""))?;
t.restyle(start, end, |s| s.line_height = lh);
}
"letterSpacing" => {
let ls = letter_spacing(v).ok_or_else(|| bad(cmd, "letterSpacing: px | \"2%\""))?;
t.restyle(start, end, |s| s.letter_spacing = ls);
}
"textDecoration" => {
let d: TextDecoration = enum_of(v).ok_or_else(|| bad(cmd, "textDecoration: none|underline|strikethrough"))?;
t.restyle(start, end, |s| s.decoration = d);
}
"textCase" => {
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title"))?;
t.restyle(start, end, |s| s.case = c);
}
"hyperlink" => {
let l = string_val(v);
t.restyle(start, end, |s| s.link = l.clone());
}
"textFills" => {
let p = if v.is_null() { None } else { Some(paints_of(v).ok_or_else(|| bad(cmd, "textFills: paints"))?) };
t.restyle(start, end, |s| s.fills = p.clone());
}
_ => {}
}
Ok(())
}
pub fn weight_name(w: u16) -> &'static str {
match w {
0..=149 => "Thin",
150..=249 => "Extra Light",
250..=349 => "Light",
350..=449 => "Regular",
450..=549 => "Medium",
550..=649 => "Semi Bold",
650..=749 => "Bold",
750..=849 => "Extra Bold",
_ => "Black",
}
}
fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal: bool) -> Result<()> {
let parent_al = doc.get(id)?.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
let n = doc.get_mut(id)?;
match s {
Sizing::Fill if !parent_al => return Err(bad(cmd, "Fill needs an auto layout parent")),
Sizing::Hug if n.layout.is_none() && !matches!(n.kind, NodeKind::Text(_)) => return Err(bad(cmd, "Hug needs auto layout or text")),
_ => {}
}
if horizontal {
n.child_layout.sizing_h = s;
} else {
n.child_layout.sizing_v = s;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
}
Ok(())
}
/// Resize a node's box from its top-left, applying constraints / group scaling to children.
pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> {
let n = doc.get(id)?;
let (ow, oh) = (n.width, n.height);
let group = n.kind.bounds_follow_children();
figcraft_layout::set_size(doc, id, w, h);
if group {
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
figcraft_layout::scale_group_children(doc, id, kx, ky);
} else if doc.get(id)?.layout.is_none() {
figcraft_layout::apply_constraints(doc, id, ow, oh);
}
Ok(())
}
/// The design-panel view of a node (used by the UI and inspect).
pub fn describe(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let c = n.transform.as_coeffs();
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
"x": c[4], "y": c[5],
"width": n.width, "height": n.height,
"rotation": figcraft_geom::round_to(figcraft_geom::rotation_degrees(n.transform), 3),
"visible": n.visible, "locked": n.locked,
"opacity": n.opacity, "blend": n.blend,
});
if let Some(b) = doc.world_bounds(id) {
v["absoluteBounds"] = json!({"x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()});
}
let _ = Point::ZERO;
let _ = Color::BLACK;
v
}

View File

@ -0,0 +1,108 @@
//! Prototype commands: interactions, flows, settings, presenting.
use figcraft_doc::proto::*;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|afterDelay|mouseEnter|mouseLeave|keyPress,..}, action {type: navigate|back|scrollTo|openOverlay|swapOverlay|closeOverlay|openUrl|changeTo|setVariable, dest?, url?}, transition? {type: instant|dissolve|smartAnimate|moveIn|push|slideIn,..}, durationMs?, easing?} — or {id, dest} for a click-navigate",
true,
add
),
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
let k = req_num("proto.interaction.remove", p, "index")? as usize;
for i in req_targets("proto.interaction.remove", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
if k < n.interactions.len() {
n.interactions.remove(k);
}
}
ok()
}),
spec!("proto.interaction.set", "Edit interaction", "{id?, index, interaction {..}}", true, |s, p| {
let k = req_num("proto.interaction.set", p, "index")? as usize;
let it: Interaction = parse("proto.interaction.set", p, "interaction")?.ok_or_else(|| bad("proto.interaction.set", "`interaction` required"))?;
for i in req_targets("proto.interaction.set", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
if let Some(slot) = n.interactions.get_mut(k) {
*slot = it.clone();
}
}
ok()
}),
spec!("proto.flow.add", "Add flow starting point", "{start? (frame id; default selection), name?}", true, |s, p| {
let start = id(p, "start").or_else(|| s.selection().first().copied()).ok_or_else(|| bad("proto.flow.add", "select a top-level frame"))?;
let page = s.page()?;
let doc = s.doc_mut()?;
let count = match &doc.get(page)?.kind {
NodeKind::Page { flows, .. } => flows.len(),
_ => 0,
};
let name = string(p, "name").unwrap_or_else(|| format!("Flow {}", count + 1));
if let NodeKind::Page { flows, .. } = &mut doc.get_mut(page)?.kind {
flows.retain(|f| f.start != start);
flows.push(Flow { name, start, description: String::new() });
}
ok()
}),
spec!("proto.flow.remove", "Remove flow", "{start}", true, |s, p| {
let start = req_id("proto.flow.remove", p, "start")?;
let page = s.page()?;
if let NodeKind::Page { flows, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
flows.retain(|f| f.start != start);
}
ok()
}),
spec!("proto.settings", "Prototype settings", "{device?, background?}", true, |s, p| {
let page = s.page()?;
let bg = color(p, "background");
let dev = string(p, "device");
if let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
if let Some(c) = bg {
prototype.background = c;
}
if let Some(d) = dev {
prototype.device = d;
}
}
ok()
}),
spec!("proto.present", "Present", "{start? (frame id)}", false, |s, p| {
let start = id(p, "start").or_else(|| {
let ds = s.ds().ok()?;
ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).or_else(|| match &ds.doc.node(ds.page)?.kind {
NodeKind::Page { flows, .. } => flows.first().map(|f| f.start),
_ => None,
}).or_else(|| ds.doc.top_frames(ds.page).first().copied())
});
let start = start.ok_or_else(|| bad("proto.present", "no frame to present"))?;
s.request_ui("present", json!({"start": start}));
Ok(json!({"start": start}))
}),
]
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.interaction.add";
let it = if let Some(d) = id(p, "dest") {
Interaction { trigger: Trigger::OnClick, actions: vec![Action::Navigate { dest: d }], transition: Transition::Instant, duration_ms: 300, easing: Easing::EaseOut }
} else {
let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default();
let action: Action = parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default();
let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default();
Interaction { trigger, actions: vec![action], transition, duration_ms: num_or(p, "durationMs", 300.0).clamp(0.0, 60_000.0) as u32, easing }
};
for i in req_targets(cmd, s, p)? {
s.doc_mut()?.get_mut(i)?.interactions.push(it.clone());
}
ok()
}

View File

@ -0,0 +1,465 @@
//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!("object.mask", "Use as mask", "{ids?}", true, mask),
spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| {
for i in req_targets("object.releaseMask", s, p)? {
s.doc_mut()?.get_mut(i)?.is_mask = false;
}
ok()
}),
spec!("bool.union", "Union selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Union))),
spec!("bool.subtract", "Subtract selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Subtract))),
spec!("bool.intersect", "Intersect selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Intersect))),
spec!("bool.exclude", "Exclude selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Exclude))),
spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten),
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| {
for i in req_targets("layout.remove", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
n.layout = None;
n.child_layout.sizing_h = Sizing::Fixed;
n.child_layout.sizing_v = Sizing::Fixed;
}
// Children that filled become fixed.
ok()
}),
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
let doc = s.doc_mut()?;
for n in doc.ids().collect::<Vec<_>>() {
if let Some(node) = doc.node(n)
&& node.plugin_data.contains_key("figcraft.thumbnail")
{
doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail");
}
}
doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into());
ok()
}),
spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize),
spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| {
for i in req_targets("vector.reverse", s, p)? {
if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind {
for seg in &mut network.segments {
std::mem::swap(&mut seg.a, &mut seg.b);
std::mem::swap(&mut seg.ta, &mut seg.tb);
}
}
}
ok()
}),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
spec!("vector.join", "Join selection", "{ids?}", true, join),
spec!("vector.smoothJoin", "Smooth join selection", "{ids?}", true, join),
]
}
enum Wrap {
Group,
Frame,
Bool(BoolOp),
}
/// Wrap the selection in a new container at the topmost selected layer's place.
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
let ids = req_targets("object.group", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
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);
let (kind, base) = match &w {
Wrap::Group => (NodeKind::Group, "Group"),
Wrap::Frame => (NodeKind::Frame, "Frame"),
Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()),
};
let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) });
let gid = doc.alloc_id();
let mut g = Node::new(gid, kind, name, local.width(), local.height());
g.transform = Affine::translate((local.x0, local.y0));
if let Wrap::Bool(_) = w {
// A boolean group takes its look from the bottom-most shape.
if let Some(first) = ids.first().and_then(|i| doc.node(*i)) {
g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() };
g.strokes = first.strokes.clone();
g.stroke = first.stroke.clone();
g.effects = first.effects.clone();
}
g.blend = figcraft_color::BlendMode::Normal;
}
if let Wrap::Frame = w {
g.clip = true;
}
// Insert the container where the topmost layer is.
doc.insert(g, parent, index)?;
for i in &ids {
doc.reparent(*i, gid, None)?;
}
if !matches!(w, Wrap::Frame) {
let _ = doc.fit_to_children(gid);
}
s.set_selection(vec![gid]);
Ok(json!({"id": gid}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for g in ids {
let n = doc.get(g)?;
if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) {
continue;
}
let Some(parent) = n.parent else { continue };
let idx = doc.index_in_parent(g).unwrap_or(0);
let kids = n.children.clone();
// Ungrouping a group keeps its opacity on the children (multiplied).
let op = n.opacity;
for (k, c) in kids.iter().enumerate() {
doc.reparent(*c, parent, Some(idx + k))?;
if op < 1.0 {
let cn = doc.get_mut(*c)?;
cn.opacity *= op;
}
out.push(*c);
}
doc.remove(g)?;
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn mask(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.mask", s, p)?;
if ids.len() == 1 {
let doc = s.doc_mut()?;
let id = ids.first().copied().unwrap_or(NodeId(0));
let n = doc.get_mut(id)?;
n.is_mask = !n.is_mask;
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
doc.get_mut(first)?.is_mask = true;
}
Ok(r)
}
/// The outline of a node in its parent's space (shapes, text, booleans, groups).
pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option<BezPath> {
let n = doc.node(id)?;
let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?;
p.apply_affine(n.transform);
Some(p)
}
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.flatten", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut path = BezPath::new();
let mut even = false;
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Booleans flatten to their result; strokes don't get outlined.
let Some((mut op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
even |= rule == figcraft_render::FillRule::EvenOdd;
// Into the common parent's space.
let w = doc.world_transform(*i);
op.apply_affine(pw.inverse() * w);
path.extend(op.elements().iter().copied());
}
if path.elements().is_empty() {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
}
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
}
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { f.fills.clone() };
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
v.opacity = f.opacity;
v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend };
}
doc.insert(v, parent, index)?;
for i in &ids {
doc.remove(*i)?;
}
s.set_selection(vec![vid]);
Ok(json!({"id": vid}))
}
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for i in ids {
let n = doc.get(i)?.clone();
if !n.has_visible_stroke() {
continue;
}
let closed = figcraft_doc::geometry::is_closed(&n);
let Some(geom) = (match &n.kind {
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
_ => figcraft_doc::geometry::stroke_geometry(&n),
}) else {
continue;
};
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
let mut path = (*area).clone();
// Union the pieces so the outline is one clean shape.
if let Ok(u) = figcraft_pathops::simplify(&path) {
path = u;
}
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?;
let idx = doc.index_in_parent(i);
let vid = doc.alloc_id();
let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height());
v.transform = n.transform * Affine::translate((bb.x0, bb.y0));
v.fills = n.strokes.clone();
v.strokes.clear();
if n.fills.iter().any(|f| f.visible) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
{
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
return Ok(json!({"ids": out}));
}
doc.insert(v, parent, idx.map(|x| x + 1))?;
doc.remove(i)?;
out.push(vid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> = f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min);
let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min);
let vertical = span_y > span_x;
let mut sorted = boxes.clone();
sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) });
let gaps: Vec<f64> = sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect();
let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::<f64>() / gaps.len() as f64).max(0.0).round() };
let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default();
let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round);
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
let dir = string(p, "direction");
let doc = s.doc()?;
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
let targets = if all_frames {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
f.clip = false;
vec![fid]
};
let doc = s.doc_mut()?;
for f in &targets {
let mut al = infer_layout(doc, *f);
if let Some(d) = &dir {
al.mode = match d.to_ascii_lowercase().as_str() {
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
}
s.set_selection(targets.clone());
Ok(json!({"ids": targets}))
}
fn move_to_page(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.moveToPage", s, p)?;
let page = req_id("object.moveToPage", p, "page")?;
let doc = s.doc_mut()?;
if !doc.node(page).is_some_and(Node::is_page) {
return Err(bad("object.moveToPage", format!("{page} is not a page")));
}
for i in z_sorted(doc, &roots(doc, &ids)) {
doc.reparent(i, page, None)?;
}
s.set_selection(vec![]);
ok()
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.rasterize", s, p)?;
let scale = num_or(p, "scale", 2.0);
let mut out = Vec::new();
for i in ids {
let doc = s.doc()?;
let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue };
let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?;
let b = figcraft_render::export_bounds(doc, i).unwrap_or_default();
let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?;
let idx = doc.index_in_parent(i);
let pw = doc.world_transform(parent);
let name = doc.get(i)?.name.clone();
let docm = s.doc_mut()?;
let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?;
let rid = docm.alloc_id();
let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height());
r.transform = pw.inverse() * Affine::translate((b.x0, b.y0));
r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
docm.insert(r, parent, idx.map(|x| x + 1))?;
docm.remove(i)?;
out.push(rid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let tol = num_or(p, "tolerance", 0.5).max(0.01);
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
let fill = network.fill_path();
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
let regions = !network.regions.is_empty();
let mut net = VectorNetwork::from_path(&simplified);
if !regions {
net.regions.clear();
}
*network = net;
}
}
ok()
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let d = req_num("vector.offset", p, "offset")?;
for i in req_targets("vector.offset", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 { figcraft_pathops::boolean(&[path, ring], BoolOp::Union) } else { figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) };
let Ok(mut res) = res else { continue };
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
let mut net = VectorNetwork::from_path(&res);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
nm.kind = NodeKind::Vector { network: net };
nm.transform = nm.transform * Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
nm.height = bb.height();
}
ok()
}
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.join", s, p)?;
if ids.len() < 2 {
// Close a single open path.
if let Some(i) = ids.first() {
let n = s.doc_mut()?.get_mut(*i)?;
if let NodeKind::Vector { network } = &mut n.kind
&& network.regions.is_empty()
&& network.vertices.len() > 2
{
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec<usize> = (0..network.segments.len()).collect();
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
}
}
return ok();
}
flatten(s, &json!({"ids": ids}))
}

View File

@ -0,0 +1,406 @@
//! Shared styles (paint, text, effect, grid) and variables (collections, modes, bindings).
use std::collections::BTreeMap;
use figcraft_color::Color;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("style.create", "Create style", "{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}", true, create),
spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply),
spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| {
let which = req_string("style.detach", p, "which")?;
for i in req_targets("style.detach", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
match which.as_str() {
"fill" => n.styles.fill = None,
"stroke" => n.styles.stroke = None,
"effect" => n.styles.effect = None,
"grid" => n.styles.grid = None,
"text" => n.styles.text = None,
_ => return Err(bad("style.detach", "which: fill|stroke|effect|grid|text")),
}
}
ok()
}),
spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update),
spec!("style.delete", "Delete style", "{style}", true, |s, p| {
let id = req_string("style.delete", p, "style")?;
let doc = s.doc_mut()?;
let sid = doc.style(&id).map(|st| st.id.clone()).ok_or_else(|| bad("style.delete", "no such style"))?;
doc.styles.retain(|st| st.id != sid);
// Nodes keep their values but lose the link.
for nid in doc.ids().collect::<Vec<_>>() {
if let Some(n) = doc.node(nid)
&& [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].iter().any(|r| r.as_deref() == Some(sid.as_str()))
{
let n = doc.get_mut(nid)?;
for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] {
if r.as_deref() == Some(sid.as_str()) {
*r = None;
}
}
}
}
ok()
}),
spec!("style.list", "List styles", "{type?}", false, |s, p| {
let t = string(p, "type");
Ok(Value::Array(s.doc()?.styles.iter().filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t)).map(|st| serde_json::to_value(st).unwrap_or(Value::Null)).collect()))
}),
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
let id = format!("VariableCollectionId:{}", doc.variables.collections.len() + 1);
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
_ => vec!["Mode 1".into()],
};
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes });
Ok(json!({"id": id}))
}),
spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| {
let c = req_string("variable.addMode", p, "collection")?;
let name = req_string("variable.addMode", p, "name")?;
let doc = s.doc_mut()?;
let coll = doc.variables.collections.iter_mut().find(|x| x.id == c || x.name == c).ok_or_else(|| bad("variable.addMode", "no such collection"))?;
let mid = format!("{}/mode{}", coll.id, coll.modes.len() + 1);
let first = coll.modes.first().map(|m| m.id.clone());
coll.modes.push(Mode { id: mid.clone(), name });
let cid = coll.id.clone();
// New mode starts with the default mode's values.
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
if let Some(val) = first.as_ref().and_then(|f| v.values.get(f)).cloned() {
v.values.insert(mid.clone(), val);
}
}
Ok(json!({"id": mid}))
}),
spec!("variable.create", "Create variable", "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}", true, var_create),
spec!("variable.set", "Set variable value", "{variable, mode? (name or id; default first), value}", true, var_set),
spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| {
let v = req_string("variable.delete", p, "variable")?;
let doc = s.doc_mut()?;
let id = doc.variables.get(&v).map(|x| x.id.clone()).ok_or_else(|| bad("variable.delete", "no such variable"))?;
doc.variables.variables.retain(|x| x.id != id);
ok()
}),
spec!("variable.bind", "Bind variable", "{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}", true, |s, p| {
let field = req_string("variable.bind", p, "field")?;
let var = string(p, "variable");
let ids = req_targets("variable.bind", s, p)?;
let doc = s.doc_mut()?;
if let Some(v) = &var
&& doc.variables.get(v).is_none()
{
return Err(bad("variable.bind", "no such variable"));
}
let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone()));
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
}
}
}
ok()
}),
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, |s, p| {
let c = req_string("variable.setMode", p, "collection")?;
let m = req_string("variable.setMode", p, "mode")?;
let doc = s.doc()?;
let coll = doc.variables.collection(&c).ok_or_else(|| bad("variable.setMode", "no such collection"))?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?;
let cid = coll.id.clone();
let mut ids = targets(s, p);
if ids.is_empty() {
ids = vec![s.page()?];
}
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
}
ok()
}),
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
]
}
fn next_style_id(doc: &Document) -> String {
let mut k = doc.styles.len() + 1;
loop {
let id = format!("S:{k}");
if doc.style(&id).is_none() {
return id;
}
k += 1;
}
}
fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
Some(match t {
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
"text" => StyleKind::Text { style: n.kind.text()?.style.clone() },
"effect" => StyleKind::Effect { effects: n.effects.clone() },
"grid" => StyleKind::Grid { grids: n.grids.clone() },
_ => return None,
})
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.create";
let t = req_string(cmd, p, "type")?.to_ascii_lowercase();
let name = req_string(cmd, p, "name")?;
let from = id(p, "from").or_else(|| s.selection().first().copied());
let doc = s.doc_mut()?;
let kind = if let Some(v) = get(p, "value") {
match t.as_str() {
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
}
} else {
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
};
let sid = next_style_id(doc);
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind });
// The source layer becomes linked to the new style.
if let Some(f) = from
&& get(p, "value").is_none()
{
let field = match t.as_str() {
"stroke" => "strokeStyle",
"text" => "textStyle",
"effect" => "effectStyle",
"grid" => "gridStyle",
_ => "fillStyle",
};
apply_style_ref(doc, f, field, Some(&sid))?;
}
Ok(json!({"id": sid}))
}
/// Link node `id` to style `sid` for `field` (fillStyle, strokeStyle, …) and copy its value in.
pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<&str>) -> Result<()> {
let style = match sid {
Some(s) => Some(doc.style(s).cloned().ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?),
None => None,
};
let n = doc.get_mut(id)?;
match (field, &style) {
("fillStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
n.fills = paints.clone();
n.styles.fill = Some(id.clone());
}
("strokeStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
n.strokes = paints.clone();
n.styles.stroke = Some(id.clone());
}
("effectStyle", Some(Style { kind: StyleKind::Effect { effects }, id, .. })) => {
n.effects = effects.clone();
n.styles.effect = Some(id.clone());
}
("gridStyle", Some(Style { kind: StyleKind::Grid { grids }, id, .. })) => {
n.grids = grids.clone();
n.styles.grid = Some(id.clone());
}
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
if let NodeKind::Text(t) = &mut n.kind {
let keep_fills = t.style.fills.clone();
t.style = TextStyle { fills: keep_fills, ..style.clone() };
t.runs.clear();
}
n.styles.text = Some(id.clone());
}
("fillStyle", None) => n.styles.fill = None,
("strokeStyle", None) => n.styles.stroke = None,
("effectStyle", None) => n.styles.effect = None,
("gridStyle", None) => n.styles.grid = None,
("textStyle", None) => n.styles.text = None,
_ => return Err(EngineError::Other(format!("style doesn't fit `{field}`"))),
}
Ok(())
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.apply";
let st = req_string(cmd, p, "style")?;
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let style = doc.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
let field = match (&style.kind, string(p, "target").as_deref()) {
(StyleKind::Paint { .. }, Some("stroke")) => "strokeStyle",
(StyleKind::Paint { .. }, _) => "fillStyle",
(StyleKind::Text { .. }, _) => "textStyle",
(StyleKind::Effect { .. }, _) => "effectStyle",
(StyleKind::Grid { .. }, _) => "gridStyle",
};
for i in ids {
apply_style_ref(doc, i, field, Some(&style.id))?;
}
ok()
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.update";
let st = req_string(cmd, p, "style")?;
let doc = s.doc_mut()?;
let idx = doc.styles.iter().position(|x| x.id == st || x.name == st).ok_or_else(|| bad(cmd, "no such style"))?;
let from = id(p, "from");
let new_kind = if let Some(v) = get(p, "value") {
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
Some(match cur {
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
})
} else if let Some(f) = from {
let t = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
doc.node(f).and_then(|n| kind_from_node(t, n))
} else {
None
};
let Some(style) = doc.styles.get_mut(idx) else { return Err(bad(cmd, "no such style")) };
if let Some(n) = string(p, "name") {
style.name = n;
}
if let Some(d) = string(p, "description") {
style.description = d;
}
if let Some(k) = new_kind {
style.kind = k;
}
let sid = style.id.clone();
propagate_style(doc, &sid);
ok()
}
/// Re-apply style `sid` to every node linked to it.
pub fn propagate_style(doc: &mut Document, sid: &str) {
let ids: Vec<NodeId> = doc.ids().collect();
for i in ids {
let Some(n) = doc.node(i) else { continue };
let refs = n.styles.clone();
for (field, r) in [("fillStyle", &refs.fill), ("strokeStyle", &refs.stroke), ("effectStyle", &refs.effect), ("gridStyle", &refs.grid), ("textStyle", &refs.text)] {
if r.as_deref() == Some(sid) {
let _ = apply_style_ref(doc, i, field, Some(sid));
}
}
}
}
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Some(VariableValue::Alias(a.to_string()));
}
Some(match kind {
VariableType::Color => VariableValue::Color(color_of(v)?),
VariableType::Number => VariableValue::Number(f64_of(v)?),
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
})
}
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.create";
let c = req_string(cmd, p, "collection")?;
let name = req_string(cmd, p, "name")?;
let kind = match req_string(cmd, p, "type")?.to_ascii_lowercase().as_str() {
"color" => VariableType::Color,
"number" | "float" => VariableType::Number,
"string" => VariableType::String,
"boolean" | "bool" => VariableType::Boolean,
_ => return Err(bad(cmd, "type: color|number|string|boolean")),
};
let doc = s.doc_mut()?;
let coll = doc.variables.collection(&c).cloned().ok_or_else(|| bad(cmd, "no such collection"))?;
let mut values = BTreeMap::new();
if let Some(Value::Object(m)) = get(p, "values") {
for (mode, v) in m {
let mid = coll.modes.iter().find(|x| x.id == *mode || x.name == *mode).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
}
}
if let Some(v) = get(p, "value") {
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| val.clone());
}
}
let default = match kind {
VariableType::Color => VariableValue::Color(Color::BLACK),
VariableType::Number => VariableValue::Number(0.0),
VariableType::String => VariableValue::String(String::new()),
VariableType::Boolean => VariableValue::Boolean(true),
};
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| default.clone());
}
let id = format!("VariableID:{}", doc.variables.variables.len() + 1);
doc.variables.variables.push(Variable { id: id.clone(), name, collection: coll.id, kind, values, description: string(p, "description").unwrap_or_default() });
Ok(json!({"id": id}))
}
fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.set";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let mode = match string(p, "mode") {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
};
let val = get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))?;
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
slot.values.insert(mode, val);
}
ok()
}
/// Write bound variable values into nodes (called during normalisation).
pub fn apply_bindings(doc: &mut Document) {
if doc.variables.variables.is_empty() {
return;
}
let bound: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in bound {
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let mut sets = serde_json::Map::new();
for (field, var) in binds {
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
let jv = match &val {
VariableValue::Color(c) => json!(c.to_css_hex()),
VariableValue::Number(x) => json!(x),
VariableValue::String(x) => json!(x),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(_) => continue,
};
let key = match field.as_str() {
"fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
"stroke" | "strokes/0/color" => "stroke",
other => other,
};
sets.insert(key.to_string(), jv);
}
let _ = super::props::apply("variables", doc, id, &sets);
}
}

View File

@ -0,0 +1,207 @@
//! Text menu and text editing commands.
use figcraft_doc::*;
use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec, textedit};
/// Apply a style change to the edited range, or to whole text layers in the selection.
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
if let Some((id, a, b)) = textedit::selected_range(s) {
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
} else {
t.restyle(a, b, &f);
}
}
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
}
ok()
}
fn para(s: &mut Session, p: &Value, f: impl Fn(&mut TextData)) -> Result<Value> {
let ids = match textedit::selected_range(s) {
Some((id, _, _)) => vec![id],
None => req_targets("text", s, p)?,
};
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
f(t);
}
}
}
crate::normalize(doc, &before);
ok()
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
if let Some((id, a, b)) = textedit::selected_range(s) {
if let Some(NodeKind::Text(t)) = doc.node(id).map(|n| &n.kind) {
let (a, b) = if a == b { (0, t.char_count()) } else { (a, b) };
return (a..b.max(a + 1)).all(|i| pred(t.style_at(i)));
}
return false;
}
targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.spans().iter().all(|(_, _, st)| pred(st))))
}
fn toggle_bold(s: &mut Session, p: &Value) -> Result<Value> {
let bold = all_have(s, p, |st| st.weight() >= 700);
style(s, p, |st| {
let italic = st.italic();
let base = if bold { "Regular" } else { "Bold" };
st.font_style = if italic { if base == "Regular" { "Italic".into() } else { format!("{base} Italic") } } else { base.into() };
})
}
fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
let italic = all_have(s, p, TextStyle::italic);
style(s, p, |st| {
let w = super::props::weight_name(st.weight());
st.font_style = if italic { w.to_string() } else if w == "Regular" { "Italic".into() } else { format!("{w} Italic") };
})
}
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
style(s, p, |st| {
let w = st.weight() as i32 + if up { 100 } else { -100 };
let w = w.clamp(100, 900) as u16;
let name = super::props::weight_name(w);
st.font_style = if st.italic() { if name == "Regular" { "Italic".into() } else { format!("{name} Italic") } } else { name.into() };
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("text.bold", "Bold", "{ids?}", true, toggle_bold),
spec!("text.italic", "Italic", "{ids?}", true, toggle_italic),
spec!("text.underline", "Underline", "{ids?}", true, |s, p| {
let on = all_have(s, p, |st| st.decoration == TextDecoration::Underline);
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Underline })
}),
spec!("text.strikethrough", "Strikethrough", "{ids?}", true, |s, p| {
let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough);
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough })
}),
spec!("text.link", "Create link", "{url, ids?}", true, |s, p| {
let url = req_string("text.link", p, "url")?;
style(s, p, |st| st.link = Some(url.clone()))
}),
spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })),
spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list = if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })),
spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)),
spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)),
spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)),
spec!("text.alignJustify", "Justify", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Justified)),
spec!("text.sizeUp", "Increase font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size + 1.0).min(5000.0))),
spec!("text.sizeDown", "Decrease font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size - 1.0).max(1.0))),
spec!("text.weightUp", "Increase font weight", "{ids?}", true, |s, p| step_weight(s, p, true)),
spec!("text.weightDown", "Decrease font weight", "{ids?}", true, |s, p| step_weight(s, p, false)),
spec!("text.lineHeightUp", "Increase line height", "{ids?}", true, |s, p| {
style(s, p, |st| st.line_height = LineHeight::Pixels(st.line_height.resolve(st.font_size, 1.21).round() + 1.0))
}),
spec!("text.lineHeightDown", "Decrease line height", "{ids?}", true, |s, p| {
style(s, p, |st| st.line_height = LineHeight::Pixels((st.line_height.resolve(st.font_size, 1.21).round() - 1.0).max(0.0)))
}),
spec!("text.trackingUp", "Increase letter spacing", "{ids?}", true, |s, p| {
style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
LetterSpacing::Percent(v) => v + 1.0,
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0,
}))
}),
spec!("text.trackingDown", "Decrease letter spacing", "{ids?}", true, |s, p| {
style(s, p, |st| st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
LetterSpacing::Percent(v) => v - 1.0,
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0,
}))
}),
spec!("text.caseOriginal", "Original case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Original)),
spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)),
spec!("text.caseLower", "Lowercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Lower)),
spec!("text.caseTitle", "Title case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Title)),
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
ok()
}),
spec!("text.style", "Style text range", "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}", true, |s, p| {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
let len = t.char_count();
let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len);
let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len);
for (k, v) in &m {
if matches!(k.as_str(), "id" | "ids" | "start" | "end") {
continue;
}
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
ok()
}),
spec!("text.edit.begin", "Edit text", "{id, caret? (char index; default select all)}", false, |s, p| {
let id = req_id("text.edit.begin", p, "id")?;
let caret = num(p, "caret").map(|c| c.max(0.0) as usize);
textedit::begin(s, id, caret, false)?;
ok()
}),
spec!("text.edit.insert", "Type text", "{text}", true, |s, p| {
let t = string(p, "text").unwrap_or_default();
textedit::insert(s, &t)?;
ok()
}),
spec!("text.edit.key", "Text key", "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Escape|SelectAll, shift?, alt?, cmd?}", true, |s, p| {
let k = req_string("text.edit.key", p, "key")?;
textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?;
ok()
}),
spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| {
let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize;
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
ok()
}),
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
textedit::end(s)?;
ok()
}),
spec!("text.fonts", "List fonts", "{family? (list its styles)}", false, |_, p| {
Ok(match string(p, "family") {
Some(f) => json!(figcraft_text::family_styles(&f)),
None => json!(figcraft_text::family_names()),
})
}),
]
}

View File

@ -0,0 +1,203 @@
//! View menu, preferences and tools. View state is per document and drivable like everything else.
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::tools::ToolId;
use crate::{Result, Session, ViewState, bad, spec};
fn view(s: &mut Session) -> Result<&mut ViewState> {
Ok(&mut s.ds_mut()?.view)
}
fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result<Value> {
let v = view(s)?;
let slot = f(v);
*slot = boolean(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
}
fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
let v = view(s)?;
let c = (v.cx, v.cy);
v.set_zoom_at(z, c);
Ok(json!({"zoom": v.zoom}))
}
/// Figma's zoom steps.
const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0];
fn step(z: f64, up: bool) -> f64 {
if up { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02) }
}
fn zoom_frame(s: &mut Session, dir: i64) -> Result<Value> {
let ds = s.ds()?;
let frames = ds.doc.top_frames(ds.page);
if frames.is_empty() {
return ok();
}
let cur = ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).and_then(|f| frames.iter().position(|x| *x == f));
let n = frames.len() as i64;
let next = match cur {
Some(c) => (c as i64 + dir).rem_euclid(n) as usize,
None => 0,
};
let Some(f) = frames.get(next).copied() else { return ok() };
let b = ds.doc.world_bounds(f).unwrap_or_default();
let ds = s.ds_mut()?;
ds.selection = vec![f];
ds.view.fit(b, 256.0);
Ok(json!({"frame": f}))
}
fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let ds = s.ds()?;
let pages = &ds.doc.pages;
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
let n = pages.len() as i64;
let next = (i + dir).clamp(0, n - 1) as usize;
let Some(pg) = pages.get(next).copied() else { return ok() };
let ds = s.ds_mut()?;
ds.page = pg;
ds.selection.clear();
ds.zoom_to_fit();
Ok(json!({"page": pg}))
}
fn ui(s: &mut Session, kind: &str) -> Result<Value> {
s.request_ui(kind, json!({}));
ok()
}
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
let slot = f(&mut s.prefs);
*slot = boolean(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
}
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
s.set_tool(t);
Ok(json!({"tool": t.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| {
let z = view(s)?.zoom;
zoom_to(s, step(z, true))
}),
spec!("view.zoomOut", "Zoom out", "{}", false, |s, _| {
let z = view(s)?.zoom;
zoom_to(s, step(z, false))
}),
spec!("view.zoom100", "Zoom to 100%", "{}", false, |s, _| zoom_to(s, 1.0)),
spec!("view.zoom50", "Zoom to 50%", "{}", false, |s, _| zoom_to(s, 0.5)),
spec!("view.zoom200", "Zoom to 200%", "{}", false, |s, _| zoom_to(s, 2.0)),
spec!("view.zoomFit", "Zoom to fit", "{}", false, |s, _| {
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.page_content_bounds(ds.page) {
ds.view.fit(b, 256.0);
}
Ok(json!({"zoom": ds.view.zoom}))
}),
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
ds.view.fit(b, 256.0);
}
Ok(json!({"zoom": ds.view.zoom}))
}),
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
let v = view(s)?;
if let Some(z) = num(p, "zoom") {
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
}
v.cx = num_or(p, "cx", v.cx);
v.cy = num_or(p, "cy", v.cy);
if let Some(w) = num(p, "vw") {
v.vw = w.clamp(10.0, 20000.0);
}
if let Some(h) = num(p, "vh") {
v.vh = h.clamp(10.0, 20000.0);
}
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
}),
spec!("view.get", "Get view", "{}", false, |s, _| Ok(serde_json::to_value(&s.ds()?.view).unwrap_or(Value::Null))),
spec!("view.prevFrame", "Zoom to previous frame", "{}", false, |s, _| zoom_frame(s, -1)),
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.pixelPreview", "Pixel preview", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_preview)),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")),
spec!("view.themeLight", "Light theme", "{}", false, |s, _| {
s.prefs.theme = "light".into();
ok()
}),
spec!("view.themeDark", "Dark theme", "{}", false, |s, _| {
s.prefs.theme = "dark".into();
ok()
}),
spec!("view.themeSystem", "Follow system theme", "{}", false, |s, _| {
s.prefs.theme = "system".into();
ok()
}),
spec!("pref.keepTool", "Keep tool selected after use", "{on?}", false, |s, p| pref(s, p, |x| &mut x.keep_tool)),
spec!("pref.highlightHover", "Highlight layers on hover", "{on?}", false, |s, p| pref(s, p, |x| &mut x.highlight_on_hover)),
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big}))
}),
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")),
spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")),
spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "dialog.libraries")),
spec!("tool.set", "Choose tool", "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment}", false, |s, p| {
let t = req_string("tool.set", p, "tool")?;
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
tool(s, t)
}),
spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)),
spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)),
spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)),
spec!("tool.frame", "Frame", "{}", false, |s, _| tool(s, ToolId::Frame)),
spec!("tool.section", "Section", "{}", false, |s, _| tool(s, ToolId::Section)),
spec!("tool.slice", "Slice", "{}", false, |s, _| tool(s, ToolId::Slice)),
spec!("tool.rectangle", "Rectangle", "{}", false, |s, _| tool(s, ToolId::Rectangle)),
spec!("tool.line", "Line", "{}", false, |s, _| tool(s, ToolId::Line)),
spec!("tool.arrow", "Arrow", "{}", false, |s, _| tool(s, ToolId::Arrow)),
spec!("tool.ellipse", "Ellipse", "{}", false, |s, _| tool(s, ToolId::Ellipse)),
spec!("tool.polygon", "Polygon", "{}", false, |s, _| tool(s, ToolId::Polygon)),
spec!("tool.star", "Star", "{}", false, |s, _| tool(s, ToolId::Star)),
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
]
}
#[allow(dead_code)]
fn unused(_: NodeId) {}

View File

@ -0,0 +1,453 @@
//! 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. Variants are components inside a component set, named
//! `Property=Value, …`; component properties (boolean, text, instance swap, variant) are applied
//! through `prop_refs` on the layers they control.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::Value;
/// The component a node belongs to (itself, or the nearest component ancestor).
pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
}
/// The instance root containing `id` (excluding `id` itself unless it is an instance).
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })))
}
/// All instances of `component` in the document.
pub fn instances_of(doc: &Document, component: NodeId) -> Vec<NodeId> {
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
}
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result<NodeId, DocError> {
let comp = doc.get(component)?.clone();
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
let id = doc.alloc_id();
let mut root = comp.clone();
root.id = id;
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();
doc.insert(root, parent, None)?;
sync_instance(doc, id);
Ok(id)
}
fn default_props(comp: &Node) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
if let NodeKind::Component { props, .. } = &comp.kind {
for (k, d) in props {
let v = 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()),
};
out.insert(k.clone(), v);
}
}
out
}
/// Copy the overridden property groups of `from` onto `to`.
fn carry_overrides(from: &Node, to: &mut Node) {
for g in &from.overrides {
match g.as_str() {
"fills" => to.fills = from.fills.clone(),
"strokes" => {
to.strokes = from.strokes.clone();
to.stroke = from.stroke.clone();
}
"effects" => to.effects = from.effects.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();
}
}
"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.align_h = a.align_h;
b.align_v = a.align_v;
}
}
"size" => {
to.width = from.width;
to.height = from.height;
to.child_layout = from.child_layout.clone();
}
"component" => {
if let (NodeKind::Instance { component: a, props: pa }, NodeKind::Instance { component: b, props: pb }) = (&from.kind, &mut to.kind) {
*b = *a;
*pb = pa.clone();
}
}
_ => {}
}
}
to.overrides = from.overrides.clone();
}
/// Name path from the instance root (for matching layers across variants).
fn name_path(doc: &Document, root: NodeId, id: NodeId) -> String {
let mut parts = Vec::new();
let mut cur = Some(id);
while let Some(c) = cur {
if c == root {
break;
}
let Some(n) = doc.node(c) else { break };
parts.push(n.name.clone());
cur = n.parent;
if parts.len() > 256 {
break;
}
}
parts.reverse();
parts.join("/")
}
/// Rebuild instance `inst` from its main component, keeping ids and overrides.
pub fn sync_instance(doc: &mut Document, inst: NodeId) {
sync_instance_depth(doc, inst, 0);
}
fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize) {
if depth > 16 {
return;
}
let Some(root) = doc.node(inst).cloned() else { return };
let NodeKind::Instance { component, props } = &root.kind else { return };
let (component, props) = (*component, props.clone());
let Some(comp) = doc.node(component).cloned() else { return };
if doc.is_ancestor(inst, component) {
// An instance inside its own main component would recurse forever.
return;
}
// Old layers by source and by name path.
let old_ids: Vec<NodeId> = doc.subtree(inst).into_iter().filter(|i| *i != inst).collect();
let mut by_source: BTreeMap<NodeId, Node> = BTreeMap::new();
let mut by_path: BTreeMap<String, Node> = BTreeMap::new();
for i in &old_ids {
if let Some(n) = doc.node(*i) {
if let Some(s) = n.source {
by_source.insert(s, n.clone());
}
by_path.insert(name_path(doc, inst, *i), n.clone());
}
}
// Remove the old layers.
let kids = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
for c in kids {
let _ = doc.remove(c);
}
// Copy the component's layers.
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = BTreeSet::new();
for s in &src {
let path = name_path(doc, component, *s);
let old = by_source.get(s).or_else(|| by_path.get(&path));
let id = match old {
Some(o) if !doc.contains(o.id) && used.insert(o.id) => o.id,
_ => doc.alloc_id(),
};
map.insert(*s, id);
}
map.insert(component, inst);
let mut new_nodes = Vec::new();
for s in &src {
let Some(orig) = doc.node(*s).cloned() else { continue };
let Some(&nid) = map.get(s) else { continue };
let mut n = orig.clone();
n.id = nid;
n.parent = orig.parent.and_then(|p| map.get(&p).copied());
n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
// Nested instance copies keep their own main; layers mirror the main's layer.
n.source = Some(orig.source.filter(|_| false).unwrap_or(*s));
n.overrides.clear();
let path = name_path(doc, component, *s);
if let Some(old) = by_source.get(s).or_else(|| by_path.get(&path)) {
carry_overrides(old, &mut n);
}
// Component properties.
for (field, prop) in &orig.prop_refs {
let Some(val) = props.get(prop) else { continue };
match field.as_str() {
"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())
&& !n.overrides.contains("characters")
{
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)) {
*c = v;
}
}
_ => {}
}
}
new_nodes.push(n);
}
for n in new_nodes {
let id = n.id;
let parent = n.parent;
let _ = doc_insert_raw(doc, n);
if parent == Some(inst)
&& let Some(r) = doc.node_mut(inst)
&& !r.children.contains(&id)
{
r.children.push(id);
}
}
// Keep the main's child order.
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).collect();
if let Some(r) = doc.node_mut(inst) {
r.children = order;
}
// Root properties follow the main unless overridden.
if let Some(r) = doc.node_mut(inst) {
let mut fresh = comp.clone();
fresh.id = inst;
fresh.parent = r.parent;
fresh.children = r.children.clone();
fresh.transform = r.transform;
fresh.kind = r.kind.clone();
fresh.source = Some(component);
fresh.constraints = r.constraints;
fresh.child_layout = r.child_layout.clone();
fresh.interactions = r.interactions.clone();
fresh.exports = r.exports.clone();
fresh.prop_refs.clear();
fresh.locked = r.locked;
fresh.visible = r.visible;
fresh.name = r.name.clone();
fresh.var_modes = r.var_modes.clone();
// Instances keep their own size (resizing an instance is allowed).
fresh.width = r.width;
fresh.height = r.height;
carry_overrides(&r.clone(), &mut fresh);
*r = fresh;
}
// Instance size differs from main: children follow constraints.
let (cw, ch) = (comp.width, comp.height);
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((cw, ch));
if ((iw - cw).abs() > 1e-9 || (ih - ch).abs() > 1e-9) && doc.node(inst).is_some_and(|n| n.layout.is_none()) {
figcraft_layout::apply_constraints(doc, inst, cw, ch);
}
// Nested instances inside the copy.
let nested: Vec<NodeId> = doc.subtree(inst).into_iter().filter(|i| *i != inst && doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))).collect();
for n in nested {
// Only re-sync nested instances whose main was swapped by a property or override.
let needs = doc.node(n).is_some_and(|nn| nn.overrides.contains("component") || !nn.prop_refs.is_empty());
if needs {
sync_instance_depth(doc, n, depth + 1);
}
}
}
/// Insert a node whose parent/children links are already set (used when rebuilding instances).
fn doc_insert_raw(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)?;
// `insert` appended us to the parent; fix sibling order later. Restore children list.
if let Some(m) = doc.node_mut(id) {
m.children = kids;
}
// Parent may have listed us already (inserted twice): dedupe.
if let Some(p) = doc.node_mut(parent) {
let mut seen = BTreeSet::new();
p.children.retain(|c| seen.insert(*c));
}
Ok(())
}
/// Property groups that differ between two versions of a node.
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 let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
if x.characters != y.characters {
out.push("characters");
}
if x.style != y.style || x.runs != y.runs {
out.push("textStyle");
}
}
if let (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) = (&a.kind, &b.kind)
&& x != y
{
out.push("component");
}
out
}
/// After an edit: record overrides inside instances, then rebuild instances of edited components.
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]) {
// 1. Overrides for layers edited inside instances.
for id in changed {
let Some(n) = doc.node(*id) else { continue };
if n.source.is_none() || matches!(n.kind, NodeKind::Instance { .. }) && owning_instance(doc, *id) == Some(*id) && n.parent.is_some_and(|p| owning_instance(doc, p).is_none()) {
// Root instances: switching the main is not an override; other edits are.
if let (Some(b), Some(n)) = (before.node(*id), doc.node(*id))
&& matches!(n.kind, NodeKind::Instance { .. })
{
let groups: Vec<&str> = diff_groups(b, n).into_iter().filter(|g| *g != "component").collect();
if !groups.is_empty()
&& let Some(m) = doc.node_mut(*id)
{
for g in groups {
m.overrides.insert(g.to_string());
}
}
}
continue;
}
if let Some(b) = before.node(*id) {
let groups = diff_groups(b, n);
if !groups.is_empty()
&& let Some(m) = doc.node_mut(*id)
{
for g in groups {
m.overrides.insert(g.to_string());
}
}
}
}
// 2. Components whose layers changed (or lost layers).
let mut comps: BTreeSet<NodeId> = BTreeSet::new();
for id in changed {
// Edits inside an instance aren't edits of the main.
if let Some(c) = owning_component(doc, *id)
&& owning_instance(doc, *id).is_none_or(|i| !doc.is_ancestor(c, i))
{
comps.insert(c);
}
}
for id in before.ids() {
if !doc.contains(id)
&& let Some(c) = owning_component(before, id)
&& doc.contains(c)
{
comps.insert(c);
}
}
// Instance main swapped or props changed: re-sync those instances.
let mut direct: BTreeSet<NodeId> = BTreeSet::new();
for id in changed {
if let (Some(n), Some(b)) = (doc.node(*id), before.node(*id))
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
&& (c1 != c2 || p1 != p2)
{
direct.insert(*id);
}
}
let mut done: BTreeSet<NodeId> = BTreeSet::new();
let mut frontier: Vec<NodeId> = comps.into_iter().collect();
let mut rounds = 0;
while !frontier.is_empty() && rounds < 8 {
rounds += 1;
let mut next = Vec::new();
for c in frontier {
if !done.insert(c) {
continue;
}
for inst in instances_of(doc, c) {
sync_instance(doc, inst);
// The instance may live inside another component: that one changed too.
if let Some(outer) = owning_component(doc, inst)
&& !done.contains(&outer)
{
next.push(outer);
}
}
}
frontier = next;
}
for i in direct {
if doc.contains(i) {
sync_instance(doc, i);
}
}
}
/// Parse a variant name "Size=Large, State=Hover" into pairs.
pub fn variant_props(name: &str) -> BTreeMap<String, String> {
name.split(',')
.filter_map(|kv| {
let (k, v) = kv.split_once('=')?;
Some((k.trim().to_string(), v.trim().to_string()))
})
.collect()
}
/// The variant in `set` matching `wanted` best (all given properties equal).
pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap<String, String>) -> Option<NodeId> {
let s = doc.node(set)?;
s.children.iter().copied().find(|c| {
doc.node(*c).is_some_and(|n| {
let p = variant_props(&n.name);
wanted.iter().all(|(k, v)| p.get(k) == Some(v))
})
})
}

View File

@ -0,0 +1,19 @@
//! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and
//! the caller rolls the document back. It exists for bugs and doesn't replace `Result`.
use crate::{EngineError, Result};
pub fn run<T>(cmd: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
log::error!("command `{cmd}` panicked: {msg}");
Err(EngineError::Internal { cmd: cmd.to_string(), msg })
}
}
}

View File

@ -0,0 +1,219 @@
//! Document inspection as JSON, so agents can verify their work without screenshots.
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
/// A node and (to `depth`) its children, with the properties a designer would check.
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let c = n.transform.as_coeffs();
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
});
let Some(o) = v.as_object_mut() else { return v };
if !n.is_page() {
o.insert("x".into(), json!(round(c[4])));
o.insert("y".into(), json!(round(c[5])));
o.insert("width".into(), json!(round(n.width)));
o.insert("height".into(), json!(round(n.height)));
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 1e-6 {
o.insert("rotation".into(), json!(round(rot)));
}
if let Some(b) = doc.world_bounds(id) {
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
}
if !n.visible {
o.insert("visible".into(), json!(false));
}
if n.locked {
o.insert("locked".into(), json!(true));
}
if n.opacity < 1.0 {
o.insert("opacity".into(), json!(n.opacity));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
o.insert("blend".into(), json!(n.blend));
}
if !n.fills.is_empty() {
o.insert("fills".into(), paints(&n.fills));
}
if !n.strokes.is_empty() {
o.insert("strokes".into(), paints(&n.strokes));
o.insert("strokeWeight".into(), json!(n.stroke.weight));
o.insert("strokeAlign".into(), json!(n.stroke.align));
if n.stroke.sides.is_some() {
o.insert("strokeSides".into(), json!(n.stroke.sides));
}
if !n.stroke.dashes.is_empty() {
o.insert("dashes".into(), json!(n.stroke.dashes));
}
}
if !n.effects.is_empty() {
o.insert("effects".into(), serde_json::to_value(&n.effects).unwrap_or(Value::Null));
}
if n.corners.radii.iter().any(|r| *r > 0.0) {
o.insert("cornerRadius".into(), match n.corners.uniform() {
Some(r) => json!(r),
None => json!(n.corners.radii),
});
if n.corners.smoothing > 0.0 {
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
}
}
if let Some(l) = &n.layout {
o.insert("layout".into(), serde_json::to_value(l).unwrap_or(Value::Null));
}
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
}
if n.child_layout.absolute {
o.insert("absolute".into(), json!(true));
}
if !parent_al && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
}
if n.kind.is_frame_like() {
o.insert("clip".into(), json!(n.clip));
}
if n.is_mask {
o.insert("isMask".into(), json!(true));
}
if !n.grids.is_empty() {
o.insert("grids".into(), serde_json::to_value(&n.grids).unwrap_or(Value::Null));
}
if !n.exports.is_empty() {
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
}
if !n.interactions.is_empty() {
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
}
if !n.styles.is_empty() {
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
}
if !n.bound.is_empty() {
o.insert("boundVariables".into(), json!(n.bound));
}
if !n.overrides.is_empty() {
o.insert("overrides".into(), json!(n.overrides));
}
}
match &n.kind {
NodeKind::Text(t) => {
o.insert("characters".into(), json!(t.characters));
o.insert("fontFamily".into(), json!(t.style.font_family));
o.insert("fontStyle".into(), json!(t.style.font_style));
o.insert("fontSize".into(), json!(t.style.font_size));
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
if t.style.decoration != TextDecoration::None {
o.insert("textDecoration".into(), json!(t.style.decoration));
}
if !t.runs.is_empty() {
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
}
}
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
}
NodeKind::Polygon { count } => {
o.insert("pointCount".into(), json!(count));
}
NodeKind::Star { count, inner_ratio } => {
o.insert("pointCount".into(), json!(count));
o.insert("innerRadius".into(), json!(inner_ratio));
}
NodeKind::BooleanOp { op } => {
o.insert("booleanOperation".into(), json!(op));
}
NodeKind::Instance { component, props } => {
o.insert("mainComponent".into(), json!(component));
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
if !props.is_empty() {
o.insert("componentProperties".into(), json!(props));
}
}
NodeKind::Component { description, props } => {
if !description.is_empty() {
o.insert("description".into(), json!(description));
}
if !props.is_empty() {
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
}
}
NodeKind::Vector { network } => {
o.insert("vertices".into(), json!(network.vertices.len()));
o.insert("segments".into(), json!(network.segments.len()));
}
NodeKind::Page { background, flows, .. } => {
o.insert("background".into(), json!(background.to_css_hex()));
if !flows.is_empty() {
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
}
}
_ => {}
}
if !n.children.is_empty() {
if depth > 0 {
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
} else {
o.insert("childCount".into(), json!(n.children.len()));
}
}
v
}
fn round(v: f64) -> f64 {
figcraft_geom::round_to(v, 2)
}
fn paints(p: &[figcraft_color::Paint]) -> Value {
Value::Array(
p.iter()
.map(|x| match &x.kind {
figcraft_color::PaintKind::Solid { color } => {
let mut v = json!({"type": "solid", "color": color.to_css_hex()});
if x.opacity < 1.0 {
v["opacity"] = json!(x.opacity);
}
if !x.visible {
v["visible"] = json!(false);
}
v
}
_ => serde_json::to_value(x).unwrap_or(Value::Null),
})
.collect(),
)
}
/// The whole document (current page in depth, other pages summarised).
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
let doc = &ds.doc;
let pages: Vec<Value> = doc
.pages
.iter()
.map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) })
.collect();
json!({
"name": doc.name,
"path": ds.path,
"dirty": ds.is_dirty(),
"currentPage": ds.page,
"selection": ds.selection,
"pages": pages,
"styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::<Vec<_>>(),
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"textEdit": ds.text_edit,
"history": {"undo": ds.history.undo.iter().rev().take(20).map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": ds.history.redo.len()},
})
}

714
crates/engine/src/lib.rs Normal file
View File

@ -0,0 +1,714 @@
//! The FigCraft engine.
//!
//! Every user-visible action is a command with a stable id (`node.create`, `align.left`,
//! `layout.add`, `component.create`…) taking JSON params. The egui UI, the CLI, the control channel
//! and the MCP server all go through [`Session::execute`], so everything is scriptable and every
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
//! edits and are journaled the same way.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod cmd;
pub mod components;
pub mod guard;
pub mod inspect;
pub mod measure;
pub mod params;
pub mod snap;
pub mod textedit;
pub mod tools;
pub use cmd::{CommandSpec, all_commands, find_command};
pub use figcraft_doc as doc;
pub use tools::{PointerEvent, PointerKind, ToolId};
use std::collections::BTreeSet;
use std::sync::Arc;
use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("no open document")]
NoDocument,
#[error("no such node {0}")]
NoNode(NodeId),
#[error("{0}")]
Other(String),
/// A bug: the command panicked. The document was rolled back.
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
Internal { cmd: String, msg: String },
}
impl From<figcraft_doc::DocError> for EngineError {
fn from(e: figcraft_doc::DocError) -> Self {
match e {
figcraft_doc::DocError::NoNode(id) => EngineError::NoNode(id),
other => EngineError::Other(other.to_string()),
}
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub label: String,
pub doc: Arc<Document>,
pub selection: Vec<NodeId>,
pub page: NodeId,
}
#[derive(Clone, Debug, Default)]
pub struct History {
pub undo: Vec<HistoryEntry>,
pub redo: Vec<HistoryEntry>,
pub limit: usize,
}
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewState {
/// Page coordinate at the viewport centre.
pub cx: f64,
pub cy: f64,
/// Zoom (1 = 100%).
pub zoom: f64,
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
pub vw: f64,
pub vh: f64,
pub pixel_grid: bool,
pub layout_grids: bool,
pub rulers: bool,
pub outlines: bool,
pub pixel_preview: bool,
pub show_slices: bool,
pub comments: bool,
pub frame_outlines: bool,
pub snap_geometry: bool,
pub snap_objects: bool,
pub snap_pixel: bool,
pub ui_hidden: bool,
}
impl Default for ViewState {
fn default() -> Self {
Self {
cx: 0.0,
cy: 0.0,
zoom: 1.0,
vw: 1200.0,
vh: 800.0,
pixel_grid: true,
layout_grids: true,
rulers: false,
outlines: false,
pixel_preview: false,
show_slices: false,
comments: true,
frame_outlines: false,
snap_geometry: true,
snap_objects: true,
snap_pixel: true,
ui_hidden: false,
}
}
}
impl ViewState {
pub const MIN_ZOOM: f64 = 0.02;
pub const MAX_ZOOM: f64 = 256.0;
/// Page coordinate at the viewport's top-left.
pub fn origin(&self) -> (f64, f64) {
let z = self.zoom.max(1e-9);
(self.cx - self.vw / 2.0 / z, self.cy - self.vh / 2.0 / z)
}
pub fn set_zoom_at(&mut self, zoom: f64, anchor: (f64, f64)) {
let z = if zoom.is_finite() { zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM) } else { self.zoom };
let old = self.zoom.max(1e-9);
// Keep the page point under `anchor` fixed.
self.cx = anchor.0 + (self.cx - anchor.0) * old / z;
self.cy = anchor.1 + (self.cy - anchor.1) * old / z;
self.zoom = z;
}
/// Fit a page rectangle into the viewport with margin.
pub fn fit(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
if !(r.width() > 0.0 || r.height() > 0.0) || !r.x0.is_finite() {
self.cx = r.center().x;
self.cy = r.center().y;
return;
}
let margin = 64.0;
let zx = (self.vw - margin * 2.0).max(50.0) / r.width().max(1e-6);
let zy = (self.vh - margin * 2.0).max(50.0) / r.height().max(1e-6);
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
self.cx = r.center().x;
self.cy = r.center().y;
}
pub fn page_to_screen(&self, x: f64, y: f64) -> (f64, f64) {
let (ox, oy) = self.origin();
((x - ox) * self.zoom, (y - oy) * self.zoom)
}
pub fn screen_to_page(&self, x: f64, y: f64) -> (f64, f64) {
let (ox, oy) = self.origin();
let z = self.zoom.max(1e-9);
(ox + x / z, oy + y / z)
}
}
/// An in-progress gesture: the document before it started, so previews re-apply from scratch.
#[derive(Clone, Debug)]
pub struct Interaction {
pub label: String,
pub doc: Arc<Document>,
pub selection: Vec<NodeId>,
}
/// The text being edited on the canvas.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextEdit {
pub id: NodeId,
/// Caret position and selection anchor (char indices).
pub caret: usize,
pub anchor: usize,
/// The node was created by this edit (deleted if left empty).
pub created: bool,
}
/// Per-document editing state.
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Document>,
pub selection: Vec<NodeId>,
pub page: NodeId,
pub history: History,
saved_doc: Arc<Document>,
pub path: Option<String>,
pub revision: u64,
pub view: ViewState,
pub interaction: Option<Interaction>,
pub text_edit: Option<TextEdit>,
/// Hovered node (canvas hover outline).
pub hover: Option<NodeId>,
/// Vector edit mode: the vector being edited and its selected vertices.
pub vector_edit: Option<(NodeId, Vec<usize>)>,
pub uid: u64,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
if doc.pages.is_empty() {
doc.repair();
}
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
let doc = Arc::new(doc);
let mut st = Self {
saved_doc: doc.clone(),
doc,
selection: Vec::new(),
page,
history: History { limit: 1000, ..Default::default() },
path,
revision: 1,
view: ViewState::default(),
interaction: None,
text_edit: None,
hover: None,
vector_edit: None,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
};
st.zoom_to_fit();
st
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved_doc)
}
pub fn mark_saved(&mut self) {
self.saved_doc = self.doc.clone();
}
pub fn doc_mut(&mut self) -> &mut Document {
Arc::make_mut(&mut self.doc)
}
/// Fit the current page's content (or centre on the origin when empty).
pub fn zoom_to_fit(&mut self) {
match self.doc.page_content_bounds(self.page) {
Some(b) => self.view.fit(b, 1.0),
None => {
self.view.zoom = 1.0;
self.view.cx = self.view.vw / 2.0 - 100.0;
self.view.cy = self.view.vh / 2.0 - 100.0;
}
}
}
/// Drop selected ids that no longer exist or aren't on the current page.
pub fn clean_selection(&mut self) {
let doc = self.doc.clone();
let page = self.page;
self.selection.retain(|id| doc.contains(*id) && doc.page_of(*id) == Some(page) && *id != page);
let mut seen = BTreeSet::new();
self.selection.retain(|id| seen.insert(*id));
if let Some(te) = &self.text_edit
&& !doc.contains(te.id)
{
self.text_edit = None;
}
}
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Prefs {
pub nudge_small: f64,
pub nudge_big: f64,
pub keep_tool: bool,
pub rename_duplicates: bool,
pub highlight_on_hover: bool,
pub number_keys_opacity: bool,
pub show_dimensions: bool,
pub theme: String,
}
impl Default for Prefs {
fn default() -> Self {
Self {
nudge_small: 1.0,
nudge_big: 10.0,
keep_tool: false,
rename_duplicates: false,
highlight_on_hover: true,
number_keys_opacity: true,
show_dimensions: true,
theme: "light".into(),
}
}
}
/// A request from a command to the UI (open a dialog or panel, show a message). Programmatic
/// callers ignore them.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct UiRequest {
pub kind: String,
pub data: Value,
}
/// Clipboard contents (internal; the UI mirrors to the system clipboard).
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
/// Copied subtrees: (root, nodes) in z-order.
pub nodes: Vec<(NodeId, Vec<figcraft_doc::Node>)>,
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
/// Copied properties (Copy properties / Paste properties).
pub props: Option<Value>,
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
}
/// The editing session: open documents, the current tool, clipboard and prefs.
pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
pub clipboard: Clipboard,
pub prefs: Prefs,
pub tool: tools::ToolState,
pub ui_requests: Vec<UiRequest>,
/// Command log (id + params) for actions/automation.
pub journal: Vec<(String, Value)>,
/// Recently used colours.
pub recent_colors: Vec<figcraft_color::Color>,
/// The last command executed through [`Session::execute`] (for "Repeat").
pub last_command: Option<(String, Value)>,
/// Status / toast message for the UI.
pub message: Option<String>,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
impl Session {
/// A session with one new document.
pub fn new() -> Self {
let mut s = Self::empty();
s.docs.push(DocState::new(Document::new("Untitled"), None));
s
}
/// A session with no documents (the home screen).
pub fn empty() -> Self {
Self {
docs: Vec::new(),
active: 0,
clipboard: Clipboard::default(),
prefs: Prefs::default(),
tool: tools::ToolState::default(),
ui_requests: Vec::new(),
journal: Vec::new(),
recent_colors: Vec::new(),
last_command: None,
message: None,
}
}
pub fn with_document(doc: Document) -> Self {
let mut s = Self::empty();
s.docs.push(DocState::new(doc, None));
s
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
pub fn ds_mut(&mut self) -> Result<&mut DocState> {
self.docs.get_mut(self.active).ok_or(EngineError::NoDocument)
}
pub fn doc(&self) -> Result<&Document> {
Ok(self.ds()?.doc.as_ref())
}
pub fn doc_mut(&mut self) -> Result<&mut Document> {
Ok(self.ds_mut()?.doc_mut())
}
pub fn selection(&self) -> Vec<NodeId> {
self.ds().map(|d| d.selection.clone()).unwrap_or_default()
}
pub fn set_selection(&mut self, ids: Vec<NodeId>) {
if let Ok(ds) = self.ds_mut() {
ds.selection = ids;
ds.clean_selection();
ds.revision += 1;
}
}
pub fn page(&self) -> Result<NodeId> {
Ok(self.ds()?.page)
}
/// Run a command by id. Mutations become one undo step; a failure or panic leaves the document
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
let result = guard::run(id, || (spec.run)(self, params));
match &result {
Ok(_) => {
self.after_command(spec, before);
if spec.history {
self.last_command = Some((id.to_string(), params.clone()));
}
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 5000 {
self.journal.drain(..1000);
}
}
Err(_) => {
// Roll back any partial edit.
if let Some((doc, sel, page, uid)) = before
&& let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid)
&& ds.interaction.is_none()
{
ds.doc = doc;
ds.selection = sel;
ds.page = page;
}
}
}
result
}
/// Bookkeeping after a successful command: normalise what changed, push undo, bump revision.
fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) {
let Some((bdoc, bsel, bpage, uid)) = before else {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
return;
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
if changed {
normalize(ds.doc_mut(), &bdoc);
if spec.history && ds.interaction.is_none() {
ds.history.undo.push(HistoryEntry { label: spec.label.to_string(), doc: bdoc, selection: bsel, page: bpage });
if ds.history.undo.len() > ds.history.limit.max(1) {
ds.history.undo.remove(0);
}
ds.history.redo.clear();
}
}
ds.clean_selection();
ds.revision += 1;
}
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
let ds = self.ds_mut()?;
if ds.interaction.is_none() {
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() });
}
Ok(())
}
/// Replace the document with `f(snapshot)` for a live preview during a gesture.
pub fn preview(&mut self, f: impl FnOnce(&mut Document, &mut Vec<NodeId>) -> Result<()>) -> Result<()> {
let ds = self.ds_mut()?;
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
let mut doc = (*it.doc).clone();
let mut sel = it.selection.clone();
let res = f(&mut doc, &mut sel);
if res.is_ok() {
normalize(&mut doc, &it.doc);
ds.doc = Arc::new(doc);
ds.selection = sel;
ds.clean_selection();
ds.revision += 1;
}
res
}
/// Finish a gesture as one undo step (no step if nothing changed).
pub fn commit_interaction(&mut self) -> Result<()> {
let ds = self.ds_mut()?;
let Some(it) = ds.interaction.take() else { return Ok(()) };
if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc {
ds.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection, page: ds.page });
ds.history.redo.clear();
} else {
ds.doc = it.doc;
}
ds.revision += 1;
Ok(())
}
/// Abandon a gesture, restoring the document.
pub fn cancel_interaction(&mut self) -> Result<()> {
let ds = self.ds_mut()?;
if let Some(it) = ds.interaction.take() {
ds.doc = it.doc;
ds.selection = it.selection;
ds.revision += 1;
}
Ok(())
}
pub fn undo(&mut self) -> Result<bool> {
let ds = self.ds_mut()?;
ds.interaction = None;
ds.text_edit = None;
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
ds.history.redo.push(redo);
ds.doc = e.doc;
ds.selection = e.selection;
if ds.doc.pages.contains(&e.page) {
ds.page = e.page;
}
ds.clean_selection();
ds.revision += 1;
Ok(true)
}
pub fn redo(&mut self) -> Result<bool> {
let ds = self.ds_mut()?;
ds.interaction = None;
ds.text_edit = None;
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
ds.history.undo.push(undo);
ds.doc = e.doc;
ds.selection = e.selection;
if ds.doc.pages.contains(&e.page) {
ds.page = e.page;
}
ds.clean_selection();
ds.revision += 1;
Ok(true)
}
/// Queue a UI request (dialogs etc.).
pub fn request_ui(&mut self, kind: &str, data: Value) {
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
}
/// Run several commands as one undo step.
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
let before = self.ds()?.doc.clone();
let sel = self.selection();
let page = self.page()?;
let mut out = Vec::new();
let saved_len = self.ds()?.history.undo.len();
for (id, p) in cmds {
match self.execute(id, p) {
Ok(v) => out.push(v),
Err(e) => {
let ds = self.ds_mut()?;
ds.doc = before;
ds.selection = sel;
ds.history.undo.truncate(saved_len);
return Err(e);
}
}
}
let ds = self.ds_mut()?;
ds.history.undo.truncate(saved_len);
if !Arc::ptr_eq(&before, &ds.doc) {
ds.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: sel, page });
ds.history.redo.clear();
}
Ok(out)
}
/// A summary for status displays.
pub fn status(&self) -> Value {
match self.ds() {
Ok(ds) => json!({
"document": ds.doc.name,
"path": ds.path,
"dirty": ds.is_dirty(),
"page": ds.page,
"selection": ds.selection,
"tool": self.tool.tool.id(),
"zoom": ds.view.zoom,
"undo": ds.history.undo.len(),
"redo": ds.history.redo.len(),
"documents": self.docs.len(),
}),
Err(_) => json!({"documents": 0}),
}
}
}
/// Nodes whose `Arc` differs from `before` (new or edited).
pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
doc.ids()
.filter(|id| match (doc.node_arc(*id), before.node_arc(*id)) {
(Some(a), Some(b)) => !Arc::ptr_eq(a, b),
_ => true,
})
.collect()
}
/// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
/// their children, instances follow their main components, auto layout re-flows.
pub fn normalize(doc: &mut Document, before: &Document) {
let changed = changed_nodes(doc, before);
if changed.is_empty() && doc.node_count() == before.node_count() {
return;
}
let m = measure::TextMeasure;
// 0. Variable bindings and style links feed node values.
cmd::styles::apply_bindings(doc);
if doc.styles != before.styles {
let changed_styles: Vec<String> = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect();
for sid in changed_styles {
cmd::styles::propagate_style(doc, &sid);
}
}
let changed = changed_nodes(doc, before);
// 1. Text auto-size.
for id in &changed {
let Some(n) = doc.node(*id) else { continue };
if let Some((w, h)) = figcraft_text::auto_size(n)
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
{
figcraft_layout::set_size(doc, *id, w, h);
}
}
// 2. Instances follow edited main components (and record overrides inside instances).
components::sync_after_edit(doc, before, &changed);
// 3. Groups / booleans wrap their children.
let mut fit: BTreeSet<NodeId> = BTreeSet::new();
for id in changed_nodes(doc, before) {
if doc.node(id).is_some_and(|n| n.kind.bounds_follow_children()) {
fit.insert(id);
}
for a in doc.ancestors(id) {
if doc.node(a).is_some_and(|n| n.kind.bounds_follow_children()) {
fit.insert(a);
} else {
break;
}
}
}
// Removed children change their parents too.
for id in before.ids() {
if !doc.contains(id)
&& let Some(p) = before.node(id).and_then(|n| n.parent)
&& doc.node(p).is_some_and(|n| n.kind.bounds_follow_children())
{
fit.insert(p);
}
}
// Deepest first.
let mut fit: Vec<NodeId> = fit.into_iter().collect();
fit.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
for id in fit {
if doc.node(id).is_some_and(|n| n.children.is_empty()) {
// Empty groups disappear, like Figma.
let _ = doc.remove(id);
} else {
let _ = doc.fit_to_children(id);
}
}
// 4. Auto layout for affected frames.
let mut frames: BTreeSet<NodeId> = BTreeSet::new();
let changed = changed_nodes(doc, before);
for id in &changed {
let mut chain = vec![*id];
chain.extend(doc.ancestors(*id));
for c in chain {
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
frames.insert(c);
}
}
}
for id in before.ids() {
if !doc.contains(id)
&& let Some(p) = before.node(id).and_then(|n| n.parent)
{
let mut chain = vec![p];
chain.extend(doc.ancestors(p));
for c in chain {
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
frames.insert(c);
}
}
}
}
let mut frames: Vec<NodeId> = frames.into_iter().collect();
frames.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len()));
for f in frames {
figcraft_layout::layout_frame(doc, f, &m);
}
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,21 @@
//! Text measurement for the layout engine.
use figcraft_doc::{Document, NodeId, NodeKind, TextAutoResize};
pub struct TextMeasure;
impl figcraft_layout::Measure for TextMeasure {
fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64> {
let n = doc.node(id)?;
let NodeKind::Text(t) = &n.kind else { return None };
let mut t = t.clone();
if t.auto_resize == TextAutoResize::WidthAndHeight {
t.auto_resize = TextAutoResize::Height;
}
Some(figcraft_text::layout(&t, Some(width), None).height.max(1.0))
}
fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> {
let n = doc.node(id)?;
figcraft_text::auto_size(n)
}
}

151
crates/engine/src/params.rs Normal file
View File

@ -0,0 +1,151 @@
//! Parameter parsing helpers shared by commands (lenient: agents send numbers as strings, ids as
//! "12" or "0:12", colours as hex or CSS).
use figcraft_color::{BlendMode, Color, Paint};
use figcraft_doc::NodeId;
use serde_json::Value;
use crate::{EngineError, Result, Session, bad};
pub fn get<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
p.get(key).filter(|v| !v.is_null())
}
pub fn f64_of(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64().filter(|f| f.is_finite()),
Value::String(s) => s.trim().trim_end_matches("px").trim_end_matches('%').trim().parse::<f64>().ok().filter(|f| f.is_finite()),
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
}
pub fn num(p: &Value, key: &str) -> Option<f64> {
get(p, key).and_then(f64_of)
}
pub fn num_or(p: &Value, key: &str, d: f64) -> f64 {
num(p, key).unwrap_or(d)
}
pub fn req_num(cmd: &str, p: &Value, key: &str) -> Result<f64> {
num(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))
}
pub fn string(p: &Value, key: &str) -> Option<String> {
match get(p, key)? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result<String> {
string(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))
}
pub fn boolean(p: &Value, key: &str) -> Option<bool> {
match get(p, key)? {
Value::Bool(b) => Some(*b),
Value::Number(n) => n.as_f64().map(|f| f != 0.0),
Value::String(s) => match s.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Some(true),
"false" | "no" | "off" | "0" => Some(false),
_ => None,
},
_ => None,
}
}
pub fn id_of(v: &Value) -> Option<NodeId> {
match v {
Value::Number(n) => n.as_u64().map(NodeId),
Value::String(s) => s.parse().ok(),
_ => None,
}
}
pub fn id(p: &Value, key: &str) -> Option<NodeId> {
get(p, key).and_then(id_of)
}
pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
}
/// `ids` (array), `id` (single), or the current selection.
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
if let Some(Value::Array(a)) = get(p, "ids") {
return a.iter().filter_map(id_of).collect();
}
if let Some(i) = id(p, "id") {
return vec![i];
}
s.selection()
}
/// Like [`targets`] but an empty result is an error.
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let t = targets(s, p);
if t.is_empty() {
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
}
if let Ok(doc) = s.doc() {
for i in &t {
if !doc.contains(*i) {
return Err(EngineError::NoNode(*i));
}
}
}
Ok(t)
}
pub fn color_of(v: &Value) -> Option<Color> {
match v {
Value::String(s) => Color::parse_css(s),
Value::Object(_) => serde_json::from_value::<Color>(v.clone()).ok().map(Color::sanitized),
Value::Array(a) => {
let c: Vec<f32> = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect();
match c.as_slice() {
[r, g, b] => Some(Color::new(*r, *g, *b, 1.0)),
[r, g, b, a] => Some(Color::new(*r, *g, *b, *a)),
_ => None,
}
}
_ => None,
}
}
pub fn color(p: &Value, key: &str) -> Option<Color> {
get(p, key).and_then(color_of)
}
/// A paint from JSON: a colour string/object (solid) or a full paint object.
pub fn paint_of(v: &Value) -> Option<Paint> {
if let Some(c) = color_of(v) {
return Some(Paint::solid(c));
}
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
Some(p)
}
pub fn paints_of(v: &Value) -> Option<Vec<Paint>> {
match v {
Value::Array(a) => a.iter().map(paint_of).collect(),
other => paint_of(other).map(|p| vec![p]),
}
}
pub fn blend(p: &Value, key: &str) -> Option<BlendMode> {
string(p, key).and_then(|s| BlendMode::from_name(&s))
}
/// Serde-deserialize `key` leniently into `T`.
pub fn parse<T: serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str) -> Result<Option<T>> {
match get(p, key) {
None => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))),
}
}

194
crates/engine/src/snap.rs Normal file
View File

@ -0,0 +1,194 @@
//! Snapping and smart guides: while moving or resizing, edges and centres snap to nearby layers'
//! edges/centres, the parent frame, layout grid columns and page guides; the matches come back as
//! guide lines (with spacing labels) for the canvas to draw in red.
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::Rect;
use serde::Serialize;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Guide {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
pub label: Option<String>,
}
/// Snap candidates gathered once at the start of a gesture.
#[derive(Clone, Debug, Default)]
pub struct Targets {
/// Vertical lines (x) with the y-span of the rect they came from.
pub xs: Vec<(f64, f64, f64)>,
/// Horizontal lines (y) with the x-span.
pub ys: Vec<(f64, f64, f64)>,
/// Sibling boxes (for spacing labels).
pub boxes: Vec<Rect>,
}
impl Targets {
/// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and
/// layout grids, excluding the moving nodes and their descendants.
pub fn gather(doc: &Document, page: NodeId, moving: &[NodeId]) -> Targets {
let mut t = Targets::default();
let push = |r: Rect, t: &mut Targets| {
for x in [r.x0, r.center().x, r.x1] {
t.xs.push((x, r.y0, r.y1));
}
for y in [r.y0, r.center().y, r.y1] {
t.ys.push((y, r.x0, r.x1));
}
t.boxes.push(r);
};
let parent = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page);
// The parent frame's own box (and its layout grids).
if parent != page
&& let Some(b) = doc.world_bounds(parent)
{
for x in [b.x0, b.center().x, b.x1] {
t.xs.push((x, b.y0, b.y1));
}
for y in [b.y0, b.center().y, b.y1] {
t.ys.push((y, b.x0, b.x1));
}
let (gx, gy) = figcraft_layout::grid_snap_lines(doc, parent);
for x in gx {
t.xs.push((x, b.y0, b.y1));
}
for y in gy {
t.ys.push((y, b.x0, b.x1));
}
}
// Siblings and, at the top level, all top-level layers.
if let Some(p) = doc.node(parent) {
for c in &p.children {
if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) {
continue;
}
if let Some(b) = doc.world_bounds(*c) {
push(b, &mut t);
}
}
}
if let Some(NodeKind::Page { guides, .. }) = doc.node(page).map(|n| &n.kind) {
for g in guides {
if g.vertical {
t.xs.push((g.offset, -1e7, 1e7));
} else {
t.ys.push((g.offset, -1e7, 1e7));
}
}
}
t
}
/// Snap a moving box: returns the (dx, dy) correction and guides to draw.
pub fn snap_rect(&self, r: Rect, threshold: f64) -> (f64, f64, Vec<Guide>) {
let xs = [r.x0, r.center().x, r.x1];
let ys = [r.y0, r.center().y, r.y1];
let best = |cands: &[(f64, f64, f64)], vals: &[f64]| -> Option<f64> {
let mut best: Option<f64> = None;
for (line, _, _) in cands {
for v in vals {
let d = line - v;
if d.abs() <= threshold && best.is_none_or(|b| d.abs() < b.abs()) {
best = Some(d);
}
}
}
best
};
let dx = best(&self.xs, &xs).unwrap_or(0.0);
let dy = best(&self.ys, &ys).unwrap_or(0.0);
let snapped = Rect::new(r.x0 + dx, r.y0 + dy, r.x1 + dx, r.y1 + dy);
let guides = self.guides_for(snapped);
(dx, dy, guides)
}
/// Snap a single point (resize handles, drawing).
pub fn snap_point(&self, x: f64, y: f64, threshold: f64) -> (f64, f64, Vec<Guide>) {
let mut bx: Option<f64> = None;
for (line, _, _) in &self.xs {
let d = line - x;
if d.abs() <= threshold && bx.is_none_or(|b| d.abs() < b.abs()) {
bx = Some(d);
}
}
let mut by: Option<f64> = None;
for (line, _, _) in &self.ys {
let d = line - y;
if d.abs() <= threshold && by.is_none_or(|b| d.abs() < b.abs()) {
by = Some(d);
}
}
let (nx, ny) = (x + bx.unwrap_or(0.0), y + by.unwrap_or(0.0));
let mut g = Vec::new();
if bx.is_some() {
let span = self.xs.iter().filter(|(l, _, _)| (l - nx).abs() < 1e-6).fold((ny, ny), |a, (_, y0, y1)| (a.0.min(*y0), a.1.max(*y1)));
g.push(Guide { x0: nx, y0: span.0, x1: nx, y1: span.1, label: None });
}
if by.is_some() {
let span = self.ys.iter().filter(|(l, _, _)| (l - ny).abs() < 1e-6).fold((nx, nx), |a, (_, x0, x1)| (a.0.min(*x0), a.1.max(*x1)));
g.push(Guide { x0: span.0, y0: ny, x1: span.1, y1: ny, label: None });
}
(bx.unwrap_or(0.0), by.unwrap_or(0.0), g)
}
/// Guide lines for edges of `r` that coincide with targets.
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
let mut out = Vec::new();
for x in [r.x0, r.center().x, r.x1] {
let hits: Vec<&(f64, f64, f64)> = self.xs.iter().filter(|(l, _, _)| (l - x).abs() < 1e-6).collect();
if !hits.is_empty() {
let y0 = hits.iter().map(|h| h.1).fold(r.y0, f64::min).max(-1e6);
let y1 = hits.iter().map(|h| h.2).fold(r.y1, f64::max).min(1e6);
out.push(Guide { x0: x, y0, x1: x, y1, label: None });
}
}
for y in [r.y0, r.center().y, r.y1] {
let hits: Vec<&(f64, f64, f64)> = self.ys.iter().filter(|(l, _, _)| (l - y).abs() < 1e-6).collect();
if !hits.is_empty() {
let x0 = hits.iter().map(|h| h.1).fold(r.x0, f64::min).max(-1e6);
let x1 = hits.iter().map(|h| h.2).fold(r.x1, f64::max).min(1e6);
out.push(Guide { x0, y0: y, x1, y1: y, label: None });
}
}
out
}
}
/// Distance lines from `r` to the nearest box on each side (Alt-hover measuring), or to `other`.
pub fn measure(r: Rect, other: Rect) -> Vec<Guide> {
let mut out = Vec::new();
let fmt = |d: f64| {
let v = (d * 100.0).round() / 100.0;
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
};
if other.contains(r.origin()) && other.contains(figcraft_geom::Point::new(r.x1, r.y1)) {
// Inside: distances to each edge of the container.
let cy = r.center().y;
let cx = r.center().x;
out.push(Guide { x0: other.x0, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x0)) });
out.push(Guide { x0: r.x1, y0: cy, x1: other.x1, y1: cy, label: Some(fmt(other.x1 - r.x1)) });
out.push(Guide { x0: cx, y0: other.y0, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y0)) });
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y1, label: Some(fmt(other.y1 - r.y1)) });
return out;
}
let cy = (r.y0.max(other.y0) + r.y1.min(other.y1)) / 2.0;
let cx = (r.x0.max(other.x0) + r.x1.min(other.x1)) / 2.0;
let cy = if r.y0.max(other.y0) <= r.y1.min(other.y1) { cy } else { r.center().y };
let cx = if r.x0.max(other.x0) <= r.x1.min(other.x1) { cx } else { r.center().x };
if other.x0 >= r.x1 {
out.push(Guide { x0: r.x1, y0: cy, x1: other.x0, y1: cy, label: Some(fmt(other.x0 - r.x1)) });
} else if other.x1 <= r.x0 {
out.push(Guide { x0: other.x1, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x1)) });
}
if other.y0 >= r.y1 {
out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y0, label: Some(fmt(other.y0 - r.y1)) });
} else if other.y1 <= r.y0 {
out.push(Guide { x0: cx, y0: other.y1, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y1)) });
}
out
}

262
crates/engine/src/tests.rs Normal file
View File

@ -0,0 +1,262 @@
use serde_json::json;
use super::*;
use figcraft_doc::NodeKind;
fn sess() -> Session {
Session::new()
}
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
#[test]
fn create_select_undo_redo() {
let mut s = sess();
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let rid = id(&r);
assert_eq!(s.selection(), vec![rid]);
assert_eq!(s.doc().unwrap().world_bounds(rid).unwrap().x0, 10.0);
assert!(s.undo().unwrap());
assert!(!s.doc().unwrap().contains(rid));
assert!(s.redo().unwrap());
assert!(s.doc().unwrap().contains(rid));
}
#[test]
fn node_set_and_errors_roll_back() {
let mut s = sess();
let rid = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("node.set", &json!({"id": rid, "props": {"fill": "#ff0000", "cornerRadius": 4, "opacity": 0.5, "name": "Red"}})).unwrap();
let n = s.doc().unwrap().node(rid).unwrap().clone();
assert_eq!(n.name, "Red");
assert_eq!(n.corners.radii, [4.0; 4]);
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF0000");
let undo = s.ds().unwrap().history.undo.len();
assert!(s.execute("node.set", &json!({"id": rid, "props": {"name": "X", "bogus": 1}})).is_err());
assert_eq!(s.doc().unwrap().node(rid).unwrap().name, "Red");
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert!(s.execute("no.such", &json!({})).is_err());
}
#[test]
fn group_ungroup_and_frame() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
let doc = s.doc().unwrap();
assert_eq!(doc.node(g).unwrap().kind.type_name(), "GROUP");
assert_eq!(doc.world_bounds(g).unwrap().width(), 60.0);
s.execute("object.ungroup", &json!({"ids": [g]})).unwrap();
assert!(!s.doc().unwrap().contains(g));
let f = id(&s.execute("object.frameSelection", &json!({"ids": [a, b]})).unwrap());
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(f));
assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 0.0);
}
#[test]
fn auto_layout_add_hugs() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 20})).unwrap());
let r = s.execute("layout.add", &json!({"ids": [a, b]})).unwrap();
let f = params::id_of(&r["ids"][0]).unwrap();
let doc = s.doc().unwrap();
let fnode = doc.node(f).unwrap();
assert!(fnode.layout.is_some());
assert_eq!(fnode.width, 100.0);
// Change gap: frame hugs.
s.execute("node.set", &json!({"id": f, "props": {"gap": 0}})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 80.0);
// Padding.
s.execute("node.set", &json!({"id": f, "props": {"padding": 10}})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 100.0);
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 40.0);
}
#[test]
fn text_create_autosize_and_edit() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap());
let w0 = s.doc().unwrap().node(t).unwrap().width;
textedit::begin(&mut s, t, Some(5), false).unwrap();
textedit::insert(&mut s, " world").unwrap();
textedit::key(&mut s, "Backspace", false, false, false).unwrap();
textedit::end(&mut s).unwrap();
let n = s.doc().unwrap().node(t).unwrap().clone();
assert_eq!(n.kind.text().unwrap().characters, "Hello worl");
if figcraft_text::find_face("Inter", "Regular").is_some() {
assert!(n.width > w0);
}
// One undo step for the whole edit session.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
}
#[test]
fn components_and_instances_sync() {
let mut s = sess();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Button"})).unwrap());
let label = id(&s.execute("text.create", &json!({"text": "OK", "x": 10, "y": 10, "parent": f})).unwrap());
let c = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
assert_eq!(c, f);
let i = id(&s.execute("instance.create", &json!({"component": c, "x": 200, "y": 0})).unwrap());
let doc = s.doc().unwrap();
assert_eq!(doc.node(i).unwrap().children.len(), 1);
let inst_label = doc.node(i).unwrap().children[0];
// Edit the main: instance follows.
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
assert_eq!(s.doc().unwrap().node(i).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
// Override text in the instance, then change main text: override wins.
s.execute("text.set", &json!({"id": inst_label, "characters": "Cancel"})).unwrap();
s.execute("text.set", &json!({"id": label, "characters": "Yes"})).unwrap();
let doc = s.doc().unwrap();
let kid = doc.node(i).unwrap().children[0];
assert_eq!(kid, inst_label, "ids are stable across syncs");
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Cancel");
// Reset clears overrides.
s.execute("component.resetInstance", &json!({"ids": [i]})).unwrap();
let doc = s.doc().unwrap();
let kid = doc.node(i).unwrap().children[0];
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Yes");
// Detach.
s.execute("component.detach", &json!({"ids": [i]})).unwrap();
assert_eq!(s.doc().unwrap().node(i).unwrap().kind.type_name(), "FRAME");
assert!(s.doc().unwrap().validate().is_empty());
}
#[test]
fn variants_switch() {
let mut s = sess();
let a = id(&s.execute("node.create", &json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}})).unwrap());
let b = id(&s.execute("node.create", &json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}})).unwrap());
s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap();
let i = id(&s.execute("instance.create", &json!({"component": a, "x": 0, "y": 200})).unwrap());
s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Hover"}})).unwrap();
let n = s.doc().unwrap().node(i).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Instance { component, .. } if component == b));
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "0000FF");
}
#[test]
fn styles_and_variables() {
let mut s = sess();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#123456"}})).unwrap());
let st = s.execute("style.create", &json!({"type": "paint", "name": "Brand/Primary", "from": r})).unwrap();
let sid = st["id"].as_str().unwrap().to_string();
let r2 = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("style.apply", &json!({"ids": [r2], "style": "Brand/Primary"})).unwrap();
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "123456");
s.execute("style.update", &json!({"style": sid, "value": ["#ff00ff"]})).unwrap();
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
// Variables with modes.
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}})).unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FFFFFF");
let page = s.page().unwrap();
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Theme", "mode": "Dark"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "000000");
}
#[test]
fn align_distribute_zorder() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 30, "y": 20, "width": 10, "height": 10})).unwrap());
let c = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 5, "width": 10, "height": 10})).unwrap());
s.execute("align.top", &json!({"ids": [a, b, c]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().y0, 0.0);
s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().x0, 50.0);
s.execute("arrange.back", &json!({"ids": [c]})).unwrap();
let page = s.page().unwrap();
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
}
#[test]
fn copy_paste_and_duplicate() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap());
s.execute("edit.copy", &json!({"ids": [a]})).unwrap();
let r = s.execute("edit.paste", &json!({})).unwrap();
let pid = params::id_of(&r["ids"][0]).unwrap();
assert_ne!(pid, a);
assert_eq!(s.doc().unwrap().world_bounds(pid).unwrap().x0, 5.0);
let d = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
}
#[test]
fn boolean_and_flatten() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap());
let g = id(&s.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
assert_eq!(s.doc().unwrap().node(g).unwrap().kind.type_name(), "BOOLEAN_OPERATION");
let v = id(&s.execute("vector.flatten", &json!({"ids": [g]})).unwrap());
let n = s.doc().unwrap().node(v).unwrap().clone();
assert_eq!(n.kind.type_name(), "VECTOR");
assert_eq!((n.width, n.height), (30.0, 30.0));
}
#[test]
fn pointer_draw_move_resize() {
let mut s = sess();
s.set_tool(ToolId::Rectangle);
let z = 1.0;
s.pointer(&PointerEvent::at(PointerKind::Down, 10.0, 10.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 60.0, 40.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 60.0, 40.0, z)).unwrap();
let r = s.selection()[0];
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap(), figcraft_geom::Rect::new(10.0, 10.0, 60.0, 40.0));
assert_eq!(s.tool.tool, ToolId::Move);
let undo_len = s.ds().unwrap().history.undo.len();
// Move it.
s.pointer(&PointerEvent::at(PointerKind::Down, 30.0, 20.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 130.0, 120.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 130.0, 120.0, z)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap().x0, 110.0);
assert_eq!(s.ds().unwrap().history.undo.len(), undo_len + 1);
// Resize from the bottom-right corner.
s.pointer(&PointerEvent::at(PointerKind::Down, 160.0, 140.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 210.0, 190.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 210.0, 190.0, z)).unwrap();
let b = s.doc().unwrap().world_bounds(r).unwrap();
assert_eq!((b.width(), b.height()), (100.0, 80.0));
// Marquee empty space deselects then selects.
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 500.0, z)).unwrap();
assert!(s.selection().is_empty());
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 100.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, z)).unwrap();
assert_eq!(s.selection(), vec![r]);
}
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
// which would fail this test).
let junk = [json!({}), json!(null), json!({"ids": ["x", -1, 99999]}), json!({"id": "nope", "x": "NaN", "props": 5}), json!([1, 2, 3])];
for spec in all_commands() {
if matches!(spec.id, "file.open" | "file.save" | "file.saveAs" | "file.import" | "file.export" | "file.exportSvg" | "file.placeImage" | "image.place" | "file.revert") {
continue;
}
for j in &junk {
let mut s = sess();
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let _ = s.execute("text.create", &json!({"text": "t"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
#[test]
fn parity_catalog_mostly_covered() {
let (done, total, missing) = catalog::parity(&[]);
assert!(total > 150);
assert!(done as f64 / total as f64 > 0.8, "{done}/{total} missing: {missing:?}");
}

View File

@ -0,0 +1,263 @@
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
use figcraft_doc::{NodeId, NodeKind, TextData};
use crate::{EngineError, Result, Session, TextEdit};
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
match &s.doc()?.get(id)?.kind {
NodeKind::Text(t) => Ok(t.clone()),
_ => Err(EngineError::Other(format!("{id} is not a text layer"))),
}
}
/// Start editing text node `id` with the caret at `caret` (None = select all).
pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -> Result<()> {
let t = text_of(s, id)?;
let n = t.char_count();
s.begin_interaction("Edit text")?;
let ds = s.ds_mut()?;
let (caret, anchor) = match caret {
Some(c) => (c.min(n), c.min(n)),
None => (n, 0),
};
ds.text_edit = Some(TextEdit { id, caret, anchor, created });
ds.selection = vec![id];
ds.revision += 1;
Ok(())
}
/// Finish editing: an empty new text layer is removed; the session becomes one undo step.
pub fn end(s: &mut Session) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
s.ds_mut()?.text_edit = None;
let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
if empty {
let doc = s.doc_mut()?;
let _ = doc.remove(te.id);
s.ds_mut()?.selection.clear();
}
s.commit_interaction()?;
if empty && te.created {
// Nothing to undo for a text box that was never filled.
let ds = s.ds_mut()?;
if let Some(last) = ds.history.undo.last()
&& *last.doc == *ds.doc
{
ds.history.undo.pop();
}
}
let ds = s.ds_mut()?;
ds.revision += 1;
Ok(())
}
fn range(te: &TextEdit) -> (usize, usize) {
(te.caret.min(te.anchor), te.caret.max(te.anchor))
}
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
let ds = s.ds_mut()?;
let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let before = ds.doc.clone();
let doc = ds.doc_mut();
let n = doc.get_mut(te.id)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
f(t, &mut te);
let len = t.char_count();
te.caret = te.caret.min(len);
te.anchor = te.anchor.min(len);
if n.source.is_some() {
n.overrides.insert("characters".into());
}
crate::normalize(doc, &before);
ds.text_edit = Some(te);
ds.revision += 1;
Ok(())
}
/// Type `text` replacing the selection.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
edit(s, |t, te| {
let (a, b) = range(te);
t.replace_range(a, b, &text);
te.caret = a + text.chars().count();
te.anchor = te.caret;
})
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn word_left(chars: &[char], mut i: usize) -> usize {
while i > 0 && chars.get(i - 1).is_some_and(|c| !is_word(*c)) {
i -= 1;
}
while i > 0 && chars.get(i - 1).is_some_and(|c| is_word(*c)) {
i -= 1;
}
i
}
fn word_right(chars: &[char], mut i: usize) -> usize {
let n = chars.len();
while i < n && chars.get(i).is_some_and(|c| !is_word(*c)) {
i += 1;
}
while i < n && chars.get(i).is_some_and(|c| is_word(*c)) {
i += 1;
}
i
}
/// A named key: Backspace, Delete, Left, Right, Up, Down, Home, End, Enter, Escape, SelectAll.
/// `word` is Alt (word-wise), `line` is Cmd (line-wise).
pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let t = text_of(s, te.id)?;
let chars: Vec<char> = t.characters.chars().collect();
let n = chars.len();
match key {
"Escape" => return end(s),
"Enter" => return insert(s, "\n"),
"Tab" => return insert(s, "\t"),
"Backspace" | "Delete" => {
return edit(s, |t, te| {
let (a, b) = range(te);
let (a, b) = if a != b {
(a, b)
} else if key == "Backspace" {
let from = if line { line_start(&chars, a) } else if word { word_left(&chars, a) } else { a.saturating_sub(1) };
(from, a)
} else {
let to = if line { line_end(&chars, a) } else if word { word_right(&chars, a) } else { (a + 1).min(n) };
(a, to)
};
t.replace_range(a, b, "");
te.caret = a;
te.anchor = a;
});
}
_ => {}
}
// Caret movement.
let layout = {
let doc = s.doc()?;
let node = doc.get(te.id)?;
figcraft_text::layout_node(node).map(|(l, _)| l)
};
let (a, b) = range(&te);
let cur = te.caret;
let next = match key {
"Left" if !shift && a != b => a,
"Right" if !shift && a != b => b,
"Left" if line => line_start(&chars, cur),
"Right" if line => line_end(&chars, cur),
"Left" if word => word_left(&chars, cur),
"Right" if word => word_right(&chars, cur),
"Left" => cur.saturating_sub(1),
"Right" => (cur + 1).min(n),
"Home" => line_start(&chars, cur),
"End" => line_end(&chars, cur),
"Up" | "Down" => match &layout {
_ if line => {
if key == "Up" {
0
} else {
n
}
}
Some(l) => {
let (x, li) = l.caret(cur);
let target = if key == "Up" { li.checked_sub(1) } else { Some(li + 1).filter(|k| *k < l.lines.len()) };
match target.and_then(|k| l.lines.get(k)) {
Some(line) => l.hit(x, line.top + line.height / 2.0),
None => {
if key == "Up" {
0
} else {
n
}
}
}
}
None => cur,
},
"SelectAll" => {
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.anchor = 0;
te.caret = n;
}
ds.revision += 1;
return Ok(());
}
_ => return Ok(()),
};
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.caret = next;
if !shift {
te.anchor = next;
}
}
ds.revision += 1;
Ok(())
}
fn line_start(chars: &[char], mut i: usize) -> usize {
while i > 0 && chars.get(i - 1).is_some_and(|c| *c != '\n') {
i -= 1;
}
i
}
fn line_end(chars: &[char], mut i: usize) -> usize {
while i < chars.len() && chars.get(i).is_some_and(|c| *c != '\n') {
i += 1;
}
i
}
/// Place the caret (and optionally extend the selection) at char `index`.
pub fn set_caret(s: &mut Session, index: usize, extend: bool) -> Result<()> {
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.caret = index;
if !extend {
te.anchor = index;
}
}
ds.revision += 1;
Ok(())
}
/// Select the word at `index` (double-click).
pub fn select_word(s: &mut Session, index: usize) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
let chars: Vec<char> = text_of(s, te.id)?.characters.chars().collect();
let mut a = index.min(chars.len());
let mut b = a;
while a > 0 && chars.get(a - 1).is_some_and(|c| is_word(*c)) {
a -= 1;
}
while b < chars.len() && chars.get(b).is_some_and(|c| is_word(*c)) {
b += 1;
}
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.anchor = a;
te.caret = b;
}
ds.revision += 1;
Ok(())
}
/// The selected range while editing (for styling commands), or None.
pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> {
let te = s.ds().ok()?.text_edit.clone()?;
let (a, b) = range(&te);
Some((te.id, a, b))
}

1421
crates/engine/src/tools.rs Normal file

File diff suppressed because it is too large Load Diff

17
crates/format/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "figcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft native file format (.figcraft: zip of document.json + images) and image import helpers"
[lints]
workspace = true
[dependencies]
figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
zip = { workspace = true }
sha2 = { workspace = true }
image = { workspace = true }

192
crates/format/src/lib.rs Normal file
View File

@ -0,0 +1,192 @@
//! The native `.figcraft` format.
//!
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
//! header), `images/<sha256>` for every image the document uses, and an optional
//! `thumbnail.png`. Plain JSON (the `document.json` alone) also opens. Reading is lenient:
//! structural problems are repaired (see [`Document::repair`]) and reported.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::{Read, Write};
use std::sync::Arc;
use figcraft_doc::{Document, ImageData};
use serde_json::{Value, json};
use sha2::Digest;
pub const EXTENSION: &str = "figcraft";
pub const FORMAT: &str = "figcraft";
pub const VERSION: u32 = 1;
/// Largest archive member we read (guards against zip bombs).
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
#[derive(Debug)]
pub enum FormatError {
Io(String),
Corrupt(String),
Newer(u32),
}
impl std::fmt::Display for FormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FormatError::Io(e) => write!(f, "{e}"),
FormatError::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"),
FormatError::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"),
}
}
}
impl std::error::Error for FormatError {}
/// Content hash used as an image's key.
pub fn image_hash(bytes: &[u8]) -> String {
let d = sha2::Sha256::digest(bytes);
d.iter().map(|b| format!("{b:02x}")).collect()
}
/// Register encoded image bytes with the document; returns the hash and pixel size.
pub fn add_image(doc: &mut Document, bytes: Vec<u8>) -> Result<(String, u32, u32), FormatError> {
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes))
.with_guessed_format()
.map_err(|e| FormatError::Corrupt(e.to_string()))?
.into_dimensions()
.map_err(|e| FormatError::Corrupt(format!("unsupported image: {e}")))?;
let hash = image_hash(&bytes);
doc.images.insert(hash.clone(), ImageData { bytes: Arc::new(bytes), width: w, height: h });
Ok((hash, w, h))
}
/// Serialize the document (without images) to the JSON value stored in `document.json`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?;
if let Some(o) = v.as_object_mut() {
o.insert("format".into(), json!(FORMAT));
o.insert("version".into(), json!(VERSION));
let images: Vec<Value> = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect();
o.insert("imageIndex".into(), Value::Array(images));
}
Ok(v)
}
/// Parse `document.json` content.
pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?;
if let Some(ver) = v.get("version").and_then(Value::as_u64)
&& ver > u64::from(VERSION)
{
return Err(FormatError::Newer(ver as u32));
}
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let issues = doc.validate();
if !issues.is_empty() {
doc.repair();
}
Ok((doc, issues))
}
/// Write a `.figcraft` archive to bytes.
pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
z.start_file("document.json", opts).map_err(|e| FormatError::Io(e.to_string()))?;
let json = serde_json::to_vec(&to_json(doc)?).map_err(|e| FormatError::Io(e.to_string()))?;
z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?;
for (hash, img) in &doc.images {
z.start_file(format!("images/{hash}"), stored).map_err(|e| FormatError::Io(e.to_string()))?;
z.write_all(&img.bytes).map_err(|e| FormatError::Io(e.to_string()))?;
}
if let Some(t) = thumbnail_png {
z.start_file("thumbnail.png", stored).map_err(|e| FormatError::Io(e.to_string()))?;
z.write_all(t).map_err(|e| FormatError::Io(e.to_string()))?;
}
z.finish().map_err(|e| FormatError::Io(e.to_string()))?;
}
Ok(buf.into_inner())
}
/// Read a `.figcraft` archive (or bare JSON) from bytes. Returns the document and any repairs made.
pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec<String>), FormatError> {
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
if trimmed == Some(&b'{') {
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
return from_json(text);
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let text = {
let f = z.by_name("document.json").map_err(|e| FormatError::Corrupt(e.to_string()))?;
let mut s = String::new();
f.take(MAX_MEMBER).read_to_string(&mut s).map_err(|e| FormatError::Corrupt(e.to_string()))?;
s
};
let (mut doc, issues) = from_json(&text)?;
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
for name in names {
let Ok(f) = z.by_name(&name) else { continue };
let mut data = Vec::new();
if f.take(MAX_MEMBER).read_to_end(&mut data).is_err() {
continue;
}
let hash = name.trim_start_matches("images/").to_string();
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0));
doc.images.insert(hash, ImageData { bytes: Arc::new(data), width: w, height: h });
}
Ok((doc, issues))
}
/// Save atomically: write a temp file next to `path`, then rename over it.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
let bytes = save_bytes(doc, thumbnail_png)?;
let tmp = path.with_extension("figcraft.tmp");
std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), FormatError> {
let bytes = std::fs::read(path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
load_bytes(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use figcraft_doc::NodeKind;
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
#[test]
fn roundtrip_with_images() {
let mut d = Document::new("My file");
let p = d.first_page().unwrap();
d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
let (hash, w, h) = add_image(&mut d, png()).unwrap();
assert_eq!((w, h), (3, 2));
let bytes = save_bytes(&d, None).unwrap();
let (back, issues) = load_bytes(&bytes).unwrap();
assert!(issues.is_empty());
assert_eq!(back.name, "My file");
assert_eq!(back.node_count(), d.node_count());
assert!(back.images.contains_key(&hash));
// Bare JSON opens too.
let json = serde_json::to_vec(&to_json(&d).unwrap()).unwrap();
assert!(load_bytes(&json).is_ok());
}
#[test]
fn garbage_is_an_error() {
assert!(load_bytes(b"not a zip").is_err());
assert!(load_bytes(b"{\"nope\":1}").is_err());
assert!(matches!(load_bytes(b"{\"version\":999}"), Err(FormatError::Newer(999))));
assert!(add_image(&mut Document::new("x"), b"zzz".to_vec()).is_err());
}
}

13
crates/layout/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "figcraft-layout"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft layout: auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed), constraints, layout grid geometry"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }

577
crates/layout/src/lib.rs Normal file
View File

@ -0,0 +1,577 @@
//! FigCraft layout.
//!
//! - [`layout_page`] / [`layout_frame`]: Figma-style auto layout. Children of an auto layout frame
//! are stacked horizontally or vertically (optionally wrapping) or placed on a grid, with gap,
//! padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the free
//! space). A Hug container sizes to its children. Absolute children are left alone.
//! - [`apply_constraints`]: children of a regular frame follow their constraints when it resizes.
//! - [`grid_lines`]: the column/row rectangles of a layout grid, for drawing and snapping.
//!
//! Text measurement is injected ([`Measure`]) so this crate doesn't depend on fonts.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use figcraft_doc::{
AutoLayout, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize, VConstraint,
};
use figcraft_geom::{Affine, Point, Rect};
/// Measures text for a given width (auto-height text inside a Fill container).
pub trait Measure {
/// The height a text node needs at `width`.
fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option<f64>;
/// Natural (auto width) size of a text node.
fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)>;
}
/// A measurer that never changes text sizes.
pub struct NoMeasure;
impl Measure for NoMeasure {
fn text_height(&self, _: &Document, _: NodeId, _: f64) -> Option<f64> {
None
}
fn text_size(&self, _: &Document, _: NodeId) -> Option<(f64, f64)> {
None
}
}
fn clamp_size(v: f64, min: Option<f64>, max: Option<f64>) -> f64 {
let mut v = if v.is_finite() { v.max(0.0) } else { 0.0 };
if let Some(m) = max.filter(|m| m.is_finite()) {
v = v.min(m);
}
if let Some(m) = min.filter(|m| m.is_finite()) {
v = v.max(m);
}
v
}
/// The child's box size as seen by the parent (rotation → bounding box).
fn outer_size(doc: &Document, id: NodeId) -> (f64, f64) {
let Some(n) = doc.node(id) else { return (0.0, 0.0) };
let b = figcraft_geom::transform_rect_bbox(linear(n.transform), n.local_rect());
(b.width(), b.height())
}
fn linear(a: Affine) -> Affine {
let c = a.as_coeffs();
Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0])
}
/// Place `id` so its (rotated) bounding box's top-left is at `(x, y)` in the parent.
fn place(doc: &mut Document, id: NodeId, x: f64, y: f64) {
let Some(n) = doc.node(id) else { return };
let lin = linear(n.transform);
let b = figcraft_geom::transform_rect_bbox(lin, n.local_rect());
let t = Affine::translate((x - b.x0, y - b.y0)) * lin;
if let Some(n) = doc.node_mut(id) {
n.transform = t;
}
}
/// Resize a node's own box (vectors scale their network to stay inside it).
pub fn set_size(doc: &mut Document, id: NodeId, w: f64, h: f64) {
let Some(n) = doc.node_mut(id) else { return };
let (w, h) = (if w.is_finite() { w.max(0.0) } else { n.width }, if h.is_finite() { h.max(0.0) } else { n.height });
let (ow, oh) = (n.width, n.height);
if (ow - w).abs() < 1e-9 && (oh - h).abs() < 1e-9 {
return;
}
if let NodeKind::Vector { network } = &mut n.kind {
let sx = if ow > 1e-9 { w / ow } else { 1.0 };
let sy = if oh > 1e-9 { h / oh } else { 1.0 };
network.scale(sx, sy);
}
n.width = w;
n.height = h;
}
/// Lay out every auto layout frame on a page (children before parents).
pub fn layout_page(doc: &mut Document, page: NodeId, m: &dyn Measure) {
let order = doc.subtree(page);
for id in order.into_iter().rev() {
if doc.node(id).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) {
layout_frame(doc, id, m);
}
}
}
/// Lay out the ancestors of `id` that are auto layout frames (innermost first), and `id` itself.
pub fn layout_up(doc: &mut Document, id: NodeId, m: &dyn Measure) {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
for c in chain {
if doc.node(c).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) {
layout_frame(doc, c, m);
}
}
}
struct Item {
id: NodeId,
main: f64,
cross: f64,
fill_main: bool,
fill_cross: bool,
min_main: Option<f64>,
max_main: Option<f64>,
min_cross: Option<f64>,
max_cross: Option<f64>,
}
/// Lay out one auto layout frame's children and size the frame if it hugs.
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let Some(f) = doc.node(frame) else { return };
let Some(al) = f.layout.clone() else { return };
let horizontal = matches!(al.mode, LayoutMode::Horizontal);
let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug);
let (fw, fh) = (f.width, f.height);
let stroke_pad = if al.strokes_included && f.has_visible_stroke() { f.stroke.weight } else { 0.0 };
let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 });
let (pt, pr, pb, pl) = (pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad);
let kids: Vec<NodeId> = f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect();
// Text children: refresh their natural size first.
for c in &kids {
if let Some(n) = doc.node(*c)
&& let NodeKind::Text(t) = &n.kind
&& t.auto_resize == TextAutoResize::WidthAndHeight
&& let Some((w, h)) = m.text_size(doc, *c)
{
set_size(doc, *c, w, h);
}
}
if matches!(al.mode, LayoutMode::Grid) {
layout_grid(doc, frame, &al, &kids, (pt, pr, pb, pl), m);
return;
}
let inner_main_fixed = if horizontal { fw - pl - pr } else { fh - pt - pb };
let inner_cross_fixed = if horizontal { fh - pt - pb } else { fw - pl - pr };
let hug_main = if horizontal { hug_w } else { hug_h };
let hug_cross = if horizontal { hug_h } else { hug_w };
let mut items: Vec<Item> = Vec::with_capacity(kids.len());
for c in &kids {
let Some(n) = doc.node(*c) else { continue };
let (w, h) = outer_size(doc, *c);
let cl = &n.child_layout;
let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
let (min_main, max_main, min_cross, max_cross) = if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
items.push(Item {
id: *c,
main: if horizontal { w } else { h },
cross: if horizontal { h } else { w },
fill_main: sm == Sizing::Fill && !hug_main,
fill_cross: sc == Sizing::Fill && !hug_cross,
min_main,
max_main,
min_cross,
max_cross,
});
}
let gap = if al.gap.is_finite() { al.gap } else { 0.0 };
let between = al.main_align == MainAlign::SpaceBetween;
// Split into lines (wrap only on the main axis of a horizontal layout, like Figma).
let mut lines: Vec<Vec<usize>> = Vec::new();
if al.wrap && !hug_main {
let mut cur: Vec<usize> = Vec::new();
let mut used = 0.0;
for (i, it) in items.iter().enumerate() {
let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
lines.push(std::mem::take(&mut cur));
used = sz;
} else {
used += add;
}
cur.push(i);
}
if !cur.is_empty() {
lines.push(cur);
}
} else {
lines.push((0..items.len()).collect());
}
// Resolve main sizes per line.
let mut line_extents: Vec<(f64, f64)> = Vec::new(); // (main used, cross size)
for line in &lines {
let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
let gaps = gap * line.len().saturating_sub(1) as f64;
let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
if !fills.is_empty() {
let mut free = (inner_main_fixed - fixed - if between { 0.0 } else { gaps }).max(0.0);
let mut open: Vec<usize> = fills.clone();
// Distribute, honouring min/max (a few rounds).
for _ in 0..4 {
if open.is_empty() {
break;
}
let share = free / open.len() as f64;
let mut still = Vec::new();
let mut used = 0.0;
for i in &open {
let Some(it) = items.get_mut(*i) else { continue };
let v = clamp_size(share, it.min_main, it.max_main);
it.main = v;
if (v - share).abs() > 1e-9 {
used += v;
} else {
still.push(*i);
}
}
if still.len() == open.len() {
break;
}
free = (free - used).max(0.0);
open = still;
}
}
let main_used: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum::<f64>() + gaps;
let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
line_extents.push((main_used, cross));
}
// Container size.
let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
let cross_gap = if al.cross_gap.is_finite() { al.cross_gap } else { 0.0 };
let content_cross: f64 = line_extents.iter().map(|l| l.1).sum::<f64>() + cross_gap * lines.len().saturating_sub(1) as f64;
let (mut nw, mut nh) = (fw, fh);
let fcl = doc.node(frame).map(|n| n.child_layout.clone()).unwrap_or_default();
if hug_main {
let v = content_main + if horizontal { pl + pr } else { pt + pb };
if horizontal {
nw = clamp_size(v, fcl.min_w, fcl.max_w);
} else {
nh = clamp_size(v, fcl.min_h, fcl.max_h);
}
}
if hug_cross {
// Fill-cross children inside a hugging container use their own size.
let max_fill_cross = items.iter().filter(|it| it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
let cc = if lines.len() <= 1 { content_cross.max(max_fill_cross) } else { content_cross };
let v = cc + if horizontal { pt + pb } else { pl + pr };
if horizontal {
nh = clamp_size(v, fcl.min_h, fcl.max_h);
} else {
nw = clamp_size(v, fcl.min_w, fcl.max_w);
}
}
set_size(doc, frame, nw, nh);
let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb };
let inner_cross = if horizontal { nh - pt - pb } else { nw - pl - pr };
let _ = inner_cross_fixed;
// Position.
let mut cross_pos = if horizontal { pt } else { pl };
let single = lines.len() == 1;
for (li, line) in lines.iter().enumerate() {
let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
let line_cross = if single { inner_cross } else { line_cross };
let n_items = line.len();
let (mut pos, step_gap) = match al.main_align {
MainAlign::Min => (0.0, gap),
MainAlign::Center => ((inner_main - used) / 2.0, gap),
MainAlign::Max => (inner_main - used, gap),
MainAlign::SpaceBetween => {
let total: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum();
if n_items > 1 { (0.0, (inner_main - total) / (n_items - 1) as f64) } else { ((inner_main - total) / 2.0, 0.0) }
}
};
pos += if horizontal { pl } else { pt };
for i in line {
let Some(it) = items.get(*i) else { continue };
let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
let off = match al.cross_align {
CrossAlign::Min | CrossAlign::Baseline => 0.0,
CrossAlign::Center => (line_cross - cross_size) / 2.0,
CrossAlign::Max => line_cross - cross_size,
};
let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
// Resize only when the layout decides the size (fill, or text that must re-wrap).
if it.fill_main || it.fill_cross {
resize_child(doc, it.id, w, h, m);
}
let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
place(doc, it.id, x, y);
pos += it.main + step_gap;
}
cross_pos += line_cross + cross_gap;
}
// Hugging after fill-cross text re-wrapped: heights may have changed; one more pass is cheap.
let _ = AutoLayout::default();
}
fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) {
let is_text_height = doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.auto_resize != TextAutoResize::None && t.auto_resize != TextAutoResize::Truncate));
if is_text_height {
if let Some(n) = doc.node_mut(id)
&& let NodeKind::Text(t) = &mut n.kind
&& t.auto_resize == TextAutoResize::WidthAndHeight
{
t.auto_resize = TextAutoResize::Height;
}
let th = m.text_height(doc, id, w).unwrap_or(h);
set_size(doc, id, w, th);
} else {
let old = doc.node(id).map(|n| (n.width, n.height));
set_size(doc, id, w, h);
if let Some((ow, oh)) = old
&& doc.node(id).is_some_and(|n| n.layout.is_none() && n.kind.is_frame_like())
{
apply_constraints(doc, id, ow, oh);
} else if doc.node(id).is_some_and(|n| n.layout.is_some()) {
layout_frame(doc, id, m);
}
}
}
fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, kids: &[NodeId], pad: (f64, f64, f64, f64), m: &dyn Measure) {
let (pt, pr, pb, pl) = pad;
let Some(f) = doc.node(frame) else { return };
let cols = al.grid_columns.clamp(1, 1000) as usize;
let rows_needed = kids.len().div_ceil(cols).max(al.grid_rows.clamp(1, 1000) as usize);
let (fw, fh) = (f.width, f.height);
let gx = if al.gap.is_finite() { al.gap.max(0.0) } else { 0.0 };
let gy = if al.cross_gap.is_finite() { al.cross_gap.max(0.0) } else { 0.0 };
let hug_w = f.child_layout.sizing_h == Sizing::Hug;
let hug_h = f.child_layout.sizing_v == Sizing::Hug;
// Track sizes: equal (1fr) unless hugging, then the max child per track.
let mut col_w = vec![0.0f64; cols];
let mut row_h = vec![0.0f64; rows_needed];
for (i, c) in kids.iter().enumerate() {
let (w, h) = outer_size(doc, *c);
if let Some(cw) = col_w.get_mut(i % cols) {
*cw = cw.max(w);
}
if let Some(rh) = row_h.get_mut(i / cols) {
*rh = rh.max(h);
}
}
let total_w = if hug_w { col_w.iter().sum::<f64>() + gx * (cols - 1) as f64 + pl + pr } else { fw };
let total_h = if hug_h { row_h.iter().sum::<f64>() + gy * (rows_needed - 1) as f64 + pt + pb } else { fh };
set_size(doc, frame, total_w, total_h);
if !hug_w {
let cw = ((total_w - pl - pr - gx * (cols - 1) as f64) / cols as f64).max(0.0);
col_w = vec![cw; cols];
}
if !hug_h {
let rh = ((total_h - pt - pb - gy * (rows_needed - 1) as f64) / rows_needed as f64).max(0.0);
row_h = vec![rh; rows_needed];
}
for (i, c) in kids.iter().enumerate() {
let (ci, ri) = (i % cols, i / cols);
let x = pl + col_w.iter().take(ci).sum::<f64>() + gx * ci as f64;
let y = pt + row_h.iter().take(ri).sum::<f64>() + gy * ri as f64;
let (cw, rh) = (col_w.get(ci).copied().unwrap_or(0.0), row_h.get(ri).copied().unwrap_or(0.0));
let Some(n) = doc.node(*c) else { continue };
let (fill_w, fill_h) = (n.child_layout.sizing_h == Sizing::Fill, n.child_layout.sizing_v == Sizing::Fill);
let (w, h) = outer_size(doc, *c);
let (nw, nh) = (if fill_w { cw } else { w }, if fill_h { rh } else { h });
if fill_w || fill_h {
resize_child(doc, *c, nw, nh, m);
}
let ox = match al.main_align {
MainAlign::Center => (cw - nw) / 2.0,
MainAlign::Max => cw - nw,
_ => 0.0,
};
let oy = match al.cross_align {
CrossAlign::Center => (rh - nh) / 2.0,
CrossAlign::Max => rh - nh,
_ => 0.0,
};
place(doc, *c, x + ox, y + oy);
}
}
/// After frame `id` (without auto layout) changed size from `(ow, oh)`, move/resize its children
/// per their constraints (recursively for children frames that resize).
pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
let Some(f) = doc.node(id) else { return };
if f.layout.is_some() {
return;
}
let (nw, nh) = (f.width, f.height);
if (nw - ow).abs() < 1e-9 && (nh - oh).abs() < 1e-9 {
return;
}
let sx = if ow > 1e-9 { nw / ow } else { 1.0 };
let sy = if oh > 1e-9 { nh / oh } else { 1.0 };
let kids = f.children.clone();
// Group children follow the group's own bounds; constraints apply to frames' children.
if f.kind.bounds_follow_children() {
return;
}
for c in kids {
let Some(n) = doc.node(c) else { continue };
let lin = linear(n.transform);
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
let (cw, ch) = (n.width, n.height);
let (bw, bh) = (b.width(), b.height());
let (mut x0, mut x1) = (b.x0, b.x1);
let (mut y0, mut y1) = (b.y0, b.y1);
match n.constraints.horizontal {
HConstraint::Left => {}
HConstraint::Right => {
x0 += nw - ow;
x1 += nw - ow;
}
HConstraint::LeftRight => x1 += nw - ow,
HConstraint::Center => {
let d = (nw - ow) / 2.0;
x0 += d;
x1 += d;
}
HConstraint::Scale => {
x0 *= sx;
x1 *= sx;
}
}
match n.constraints.vertical {
VConstraint::Top => {}
VConstraint::Bottom => {
y0 += nh - oh;
y1 += nh - oh;
}
VConstraint::TopBottom => y1 += nh - oh,
VConstraint::Center => {
let d = (nh - oh) / 2.0;
y0 += d;
y1 += d;
}
VConstraint::Scale => {
y0 *= sy;
y1 *= sy;
}
}
// New local size from the bbox change (axis-aligned children exactly; rotated approximately).
let kw = if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 };
let kh = if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 };
let (new_w, new_h) = (cw * kw, ch * kh);
let resized = (new_w - cw).abs() > 1e-9 || (new_h - ch).abs() > 1e-9;
if resized {
set_size(doc, c, new_w, new_h);
if let Some(cn) = doc.node_mut(c) {
cn.transform = lin;
}
}
place(doc, c, x0, y0);
if resized && doc.node(c).is_some_and(|n| n.kind.is_frame_like() || n.kind.bounds_follow_children()) {
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
scale_group_children(doc, c, kw, kh);
} else {
apply_constraints(doc, c, cw, ch);
}
}
}
}
/// Scale a group's children when the group is resized (groups scale their contents).
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
let Some(n) = doc.node(id) else { return };
for c in n.children.clone() {
let Some(cn) = doc.node(c) else { continue };
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
let (w, h) = (cn.width, cn.height);
set_size(doc, c, w * kx, h * ky);
place(doc, c, b.x0 * kx, b.y0 * ky);
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
scale_group_children(doc, c, kx, ky);
} else if doc.node(c).is_some_and(|n| n.kind.is_frame_like()) {
apply_constraints(doc, c, w, h);
}
}
}
/// The rectangles (frame-local) a layout grid draws: columns, rows or the square grid's lines.
pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec<Rect> {
let mut out = Vec::new();
match g.kind {
GridKind::Grid => {
let s = if g.size.is_finite() && g.size >= 1.0 { g.size } else { 10.0 };
let mut x = 0.0;
let mut guard = 0;
while x <= w && guard < 4000 {
out.push(Rect::new(x, 0.0, x, h));
x += s;
guard += 1;
}
let mut y = 0.0;
guard = 0;
while y <= h && guard < 4000 {
out.push(Rect::new(0.0, y, w, y));
y += s;
guard += 1;
}
}
GridKind::Columns | GridKind::Rows => {
let cols = g.kind == GridKind::Columns;
let total = if cols { w } else { h };
let count = g.count.clamp(1, 500) as usize;
let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 };
let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 };
let (start, size) = match g.align {
GridAlign::Stretch => {
let size = ((total - 2.0 * offset - gutter * (count - 1) as f64) / count as f64).max(0.0);
(offset, size)
}
_ => {
let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 };
let span = size * count as f64 + gutter * (count - 1) as f64;
let start = match g.align {
GridAlign::Min => offset,
GridAlign::Center => (total - span) / 2.0,
_ => total - offset - span,
};
(start, size)
}
};
for i in 0..count {
let a = start + i as f64 * (size + gutter);
out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) });
}
}
}
out
}
/// Snap targets of a frame's visible layout grids (page coordinates): column/row edges.
pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
let Some(f) = doc.node(frame) else { return (vec![], vec![]) };
let xf = doc.world_transform(frame);
let mut xs = Vec::new();
let mut ys = Vec::new();
for g in f.grids.iter().filter(|g| g.visible) {
for r in grid_lines(g, f.width, f.height) {
let a = xf * Point::new(r.x0, r.y0);
let b = xf * Point::new(r.x1, r.y1);
match g.kind {
GridKind::Columns => {
xs.push(a.x);
xs.push(b.x);
}
GridKind::Rows => {
ys.push(a.y);
ys.push(b.y);
}
GridKind::Grid => {
if (r.x0 - r.x1).abs() < 1e-9 {
xs.push(a.x);
} else {
ys.push(a.y);
}
}
}
}
}
(xs, ys)
}
#[cfg(test)]
mod tests;

156
crates/layout/src/tests.rs Normal file
View File

@ -0,0 +1,156 @@
use super::*;
use figcraft_doc::{AutoLayout, Document, NodeKind};
fn setup(mode: LayoutMode) -> (Document, NodeId, Vec<NodeId>) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode, gap: 10.0, padding: [5.0, 5.0, 5.0, 5.0], ..Default::default() });
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 50.0, 20.0).unwrap();
let b = d.create(f, NodeKind::Rectangle, "B", 0.0, 0.0, 30.0, 40.0).unwrap();
(d, f, vec![a, b])
}
fn pos(d: &Document, id: NodeId) -> (f64, f64, f64, f64) {
let n = d.node(id).unwrap();
let c = n.transform.as_coeffs();
(c[4], c[5], n.width, n.height)
}
#[test]
fn horizontal_stack() {
let (mut d, f, k) = setup(LayoutMode::Horizontal);
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(pos(&d, k[0]), (5.0, 5.0, 50.0, 20.0));
assert_eq!(pos(&d, k[1]), (65.0, 5.0, 30.0, 40.0));
}
#[test]
fn hug_container() {
let (mut d, f, _) = setup(LayoutMode::Horizontal);
let n = d.get_mut(f).unwrap();
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
layout_frame(&mut d, f, &NoMeasure);
let n = d.node(f).unwrap();
assert_eq!((n.width, n.height), (5.0 + 50.0 + 10.0 + 30.0 + 5.0, 50.0));
}
#[test]
fn vertical_center_alignment() {
let (mut d, f, k) = setup(LayoutMode::Vertical);
d.get_mut(f).unwrap().layout.as_mut().unwrap().cross_align = CrossAlign::Center;
layout_frame(&mut d, f, &NoMeasure);
// Inner width 290; A (50 wide) centred → x = 5 + 120.
assert_eq!(pos(&d, k[0]).0, 125.0);
assert_eq!(pos(&d, k[1]).1, 5.0 + 20.0 + 10.0);
}
#[test]
fn fill_children_share_space() {
let (mut d, f, k) = setup(LayoutMode::Horizontal);
for c in &k {
d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill;
}
d.get_mut(k[1]).unwrap().child_layout.sizing_v = Sizing::Fill;
layout_frame(&mut d, f, &NoMeasure);
// Inner 290 - gap 10 = 280 → 140 each.
assert_eq!(pos(&d, k[0]).2, 140.0);
assert_eq!(pos(&d, k[1]).0, 5.0 + 140.0 + 10.0);
assert_eq!(pos(&d, k[1]).3, 90.0);
// Max width on one.
d.get_mut(k[0]).unwrap().child_layout.max_w = Some(100.0);
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(pos(&d, k[0]).2, 100.0);
assert_eq!(pos(&d, k[1]).2, 180.0);
}
#[test]
fn space_between_and_absolute() {
let (mut d, f, k) = setup(LayoutMode::Horizontal);
d.get_mut(f).unwrap().layout.as_mut().unwrap().main_align = MainAlign::SpaceBetween;
let abs = d.create(f, NodeKind::Rectangle, "Abs", 7.0, 7.0, 10.0, 10.0).unwrap();
d.get_mut(abs).unwrap().child_layout.absolute = true;
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(pos(&d, k[1]).0, 300.0 - 5.0 - 30.0);
assert_eq!(pos(&d, abs).0, 7.0);
}
#[test]
fn wrap_into_rows() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { wrap: true, gap: 10.0, cross_gap: 5.0, padding: [0.0; 4], ..Default::default() });
d.get_mut(f).unwrap().child_layout.sizing_v = Sizing::Hug;
let ids: Vec<NodeId> = (0..5).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 40.0, 20.0).unwrap()).collect();
layout_frame(&mut d, f, &NoMeasure);
// Two per row (40+10+40 = 90 ≤ 100).
assert_eq!(pos(&d, ids[2]), (0.0, 25.0, 40.0, 20.0));
assert_eq!(d.node(f).unwrap().height, 20.0 * 3.0 + 5.0 * 2.0);
}
#[test]
fn grid_mode() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 210.0, 110.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 2, gap: 10.0, cross_gap: 10.0, padding: [0.0; 4], ..Default::default() });
let ids: Vec<NodeId> = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect();
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(pos(&d, ids[3]).0, 110.0);
assert_eq!(pos(&d, ids[3]).1, 60.0);
}
#[test]
fn nested_hug_propagates_with_layout_page() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let outer = d.create(p, NodeKind::Frame, "Outer", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(outer).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..Default::default() });
d.get_mut(outer).unwrap().child_layout.sizing_h = Sizing::Hug;
let inner = d.create(outer, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(inner).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() });
d.get_mut(inner).unwrap().child_layout.sizing_h = Sizing::Hug;
d.create(inner, NodeKind::Rectangle, "R", 0.0, 0.0, 77.0, 10.0).unwrap();
layout_page(&mut d, p, &NoMeasure);
assert_eq!(d.node(inner).unwrap().width, 77.0);
assert_eq!(d.node(outer).unwrap().width, 77.0);
}
#[test]
fn constraints_on_resize() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let r = d.create(f, NodeKind::Rectangle, "R", 80.0, 10.0, 10.0, 10.0).unwrap();
d.get_mut(r).unwrap().constraints = figcraft_doc::Constraints { horizontal: HConstraint::Right, vertical: VConstraint::TopBottom };
let c = d.create(f, NodeKind::Rectangle, "C", 45.0, 45.0, 10.0, 10.0).unwrap();
d.get_mut(c).unwrap().constraints = figcraft_doc::Constraints { horizontal: HConstraint::Center, vertical: VConstraint::Scale };
set_size(&mut d, f, 200.0, 200.0);
apply_constraints(&mut d, f, 100.0, 100.0);
assert_eq!(pos(&d, r), (180.0, 10.0, 10.0, 110.0));
assert_eq!(pos(&d, c), (95.0, 90.0, 10.0, 20.0));
}
#[test]
fn grid_lines_columns_stretch() {
let g = LayoutGrid { kind: GridKind::Columns, count: 4, gutter: 20.0, offset: 10.0, ..LayoutGrid::default() };
let r = grid_lines(&g, 400.0, 100.0);
assert_eq!(r.len(), 4);
assert_eq!(r[0].x0, 10.0);
assert!((r[3].x1 - 390.0).abs() < 1e-9);
}
#[test]
fn hostile_values() {
let (mut d, f, _) = setup(LayoutMode::Horizontal);
let n = d.get_mut(f).unwrap();
n.layout.as_mut().unwrap().gap = f64::NAN;
n.layout.as_mut().unwrap().padding = [f64::INFINITY, -1.0, f64::NAN, 1e300];
n.layout.as_mut().unwrap().grid_columns = 0;
layout_frame(&mut d, f, &NoMeasure);
d.get_mut(f).unwrap().layout.as_mut().unwrap().mode = LayoutMode::Grid;
layout_frame(&mut d, f, &NoMeasure);
let _ = grid_lines(&LayoutGrid { size: 0.0, ..Default::default() }, 1e9, 1e9);
}

View File

@ -11,7 +11,7 @@ pub mod images;
pub mod paint;
pub use draw::{node_outline, subtree_outline, ts_path};
pub use tiny_skia::Pixmap;
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};

18
crates/svg/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "figcraft-svg"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft SVG export (shapes, gradients, images, strokes, effects as filters, text as outlines) and import (via usvg)"
[lints]
workspace = true
[dependencies]
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-render = { workspace = true }
figcraft-pathops = { workspace = true }
usvg = { workspace = true }
base64 = { workspace = true }

225
crates/svg/src/import.rs Normal file
View File

@ -0,0 +1,225 @@
//! SVG import: usvg's normalised tree → a frame of vector nodes (groups kept as groups).
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule};
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
#[derive(Debug)]
pub struct ImportError(pub String);
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SVG import failed: {}", self.0)
}
}
fn xf(t: usvg::Transform) -> Affine {
Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)])
}
fn to_bez(p: &usvg::tiny_skia_path::Path) -> BezPath {
use usvg::tiny_skia_path::PathSegment::*;
let mut b = BezPath::new();
for s in p.segments() {
match s {
MoveTo(a) => b.move_to((f64::from(a.x), f64::from(a.y))),
LineTo(a) => b.line_to((f64::from(a.x), f64::from(a.y))),
QuadTo(c, a) => b.quad_to((f64::from(c.x), f64::from(c.y)), (f64::from(a.x), f64::from(a.y))),
CubicTo(c1, c2, a) => b.curve_to((f64::from(c1.x), f64::from(c1.y)), (f64::from(c2.x), f64::from(c2.y)), (f64::from(a.x), f64::from(a.y))),
Close => b.close_path(),
}
}
b
}
fn color(c: usvg::Color, op: f32) -> Color {
Color::new(f32::from(c.red) / 255.0, f32::from(c.green) / 255.0, f32::from(c.blue) / 255.0, op)
}
/// Convert a usvg paint into ours for a node whose local box is `bbox` (absolute coordinates) and
/// whose absolute transform is `abs`.
fn paint(p: &usvg::Paint, opacity: f32, bbox: Rect, abs: Affine) -> Option<Paint> {
let to_unit = |pt: Point| -> (f64, f64) {
let w = bbox.width().max(1e-9);
let h = bbox.height().max(1e-9);
((pt.x - bbox.x0) / w, (pt.y - bbox.y0) / h)
};
match p {
usvg::Paint::Color(c) => Some(Paint::solid(color(*c, 1.0)).with_opacity(opacity)),
usvg::Paint::LinearGradient(g) => {
let gt = abs * xf(g.transform());
let a = gt * Point::new(f64::from(g.x1()), f64::from(g.y1()));
let b = gt * Point::new(f64::from(g.x2()), f64::from(g.y2()));
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
let from = to_unit(a);
let to = to_unit(b);
Some(Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from, to, width: (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }).with_opacity(opacity))
}
usvg::Paint::RadialGradient(g) => {
let gt = abs * xf(g.transform());
let c = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()));
let r = f64::from(g.r().get());
let ex = gt * Point::new(f64::from(g.cx()) + r, f64::from(g.cy()));
let ey = gt * Point::new(f64::from(g.cx()), f64::from(g.cy()) + r);
let stops = g.stops().iter().map(|s| ColorStop { position: s.offset().get(), color: color(s.color(), s.opacity().get()) }).collect();
Some(Paint::gradient(Gradient { kind: GradientKind::Radial, stops, from: to_unit(c), to: to_unit(ex), width: to_unit(ey) }).with_opacity(opacity))
}
usvg::Paint::Pattern(_) => None,
}
}
trait WithOpacity {
fn with_opacity(self, o: f32) -> Self;
}
impl WithOpacity for Paint {
fn with_opacity(mut self, o: f32) -> Self {
self.opacity *= o.clamp(0.0, 1.0);
self
}
}
struct Ctx<'a> {
doc: &'a mut Document,
/// Inverse of the root frame's placement: absolute SVG → frame-local.
count: usize,
}
const MAX_NODES: usize = 100_000;
fn add_group(cx: &mut Ctx, g: &usvg::Group, parent: NodeId, parent_abs: Affine, depth: usize) {
if depth > 128 {
return;
}
for child in g.children() {
if cx.count > MAX_NODES {
return;
}
match child {
usvg::Node::Group(sub) => {
let abs = xf(sub.abs_transform());
let id = cx.doc.alloc_id();
let name = if sub.id().is_empty() { "Group".to_string() } else { sub.id().to_string() };
let mut n = Node::new(id, NodeKind::Group, name, 0.0, 0.0);
n.opacity = sub.opacity().get();
if cx.doc.insert(n, parent, None).is_err() {
continue;
}
cx.count += 1;
add_group(cx, sub, id, abs, depth + 1);
if cx.doc.node(id).is_some_and(|n| n.children.is_empty()) {
let _ = cx.doc.remove(id);
} else {
let _ = cx.doc.fit_to_children(id);
}
}
usvg::Node::Path(p) => {
if !p.is_visible() {
continue;
}
let abs = xf(p.abs_transform());
let mut path = to_bez(p.data());
path.apply_affine(parent_abs.inverse() * abs);
// Place the vector at its bbox origin in the parent.
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite()) {
continue;
}
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
let even = p.fill().is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd);
for r in &mut net.regions {
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
}
if p.fill().is_none() {
net.regions.clear();
}
let id = cx.doc.alloc_id();
let name = if p.id().is_empty() { "Vector".to_string() } else { p.id().to_string() };
let mut n = Node::new(id, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
n.transform = Affine::translate((bb.x0, bb.y0));
let abs_bbox = figcraft_geom::transform_rect_bbox(parent_abs, bb);
// Paint coordinates are absolute: express them in the vector's box.
n.fills = p.fill().and_then(|f| paint(f.paint(), f.opacity().get(), abs_bbox, Affine::IDENTITY)).into_iter().collect();
if let Some(s) = p.stroke() {
n.strokes = paint(s.paint(), s.opacity().get(), abs_bbox, Affine::IDENTITY).into_iter().collect();
n.stroke.weight = f64::from(s.width().get());
n.stroke.align = StrokeAlign::Center;
n.stroke.join = match s.linejoin() {
usvg::LineJoin::Round => StrokeJoin::Round,
usvg::LineJoin::Bevel => StrokeJoin::Bevel,
_ => StrokeJoin::Miter,
};
let cap = match s.linecap() {
usvg::LineCap::Round => StrokeCap::Round,
usvg::LineCap::Square => StrokeCap::Square,
usvg::LineCap::Butt => StrokeCap::None,
};
n.stroke.cap_start = cap;
n.stroke.cap_end = cap;
n.stroke.miter_limit = f64::from(s.miterlimit().get());
if let Some(d) = s.dasharray() {
n.stroke.dashes = d.iter().map(|v| f64::from(*v)).collect();
}
} else {
n.strokes.clear();
}
if cx.doc.insert(n, parent, None).is_ok() {
cx.count += 1;
}
}
usvg::Node::Text(t) => {
let flat = t.flattened();
add_group(cx, flat, parent, parent_abs, depth + 1);
}
usvg::Node::Image(img) => {
let bytes = match img.kind() {
usvg::ImageKind::PNG(b) | usvg::ImageKind::JPEG(b) | usvg::ImageKind::GIF(b) | usvg::ImageKind::WEBP(b) => b.as_ref().clone(),
usvg::ImageKind::SVG(_) => continue,
};
let hash = simple_hash(&bytes);
let abs = xf(img.abs_transform());
let local = parent_abs.inverse() * abs;
let size = img.size();
let id = cx.doc.alloc_id();
let mut n = Node::new(id, NodeKind::Rectangle, "Image", f64::from(size.width()), f64::from(size.height()));
n.transform = local;
n.fills = vec![Paint::image(hash.clone(), figcraft_color::ImageScaleMode::Fill)];
let (w, h) = (size.width() as u32, size.height() as u32);
cx.doc.images.insert(hash, figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes), width: w, height: h });
if cx.doc.insert(n, parent, None).is_ok() {
cx.count += 1;
}
}
}
}
}
fn simple_hash(b: &[u8]) -> String {
// FNV-1a 128 (two lanes); the format crate re-keys by sha256 on save if needed.
let mut h1: u64 = 0xcbf2_9ce4_8422_2325;
let mut h2: u64 = 0x8422_2325_cbf2_9ce4;
for (i, x) in b.iter().enumerate() {
h1 ^= u64::from(*x);
h1 = h1.wrapping_mul(0x0100_0000_01b3);
h2 ^= u64::from(*x).wrapping_add(i as u64);
h2 = h2.wrapping_mul(0x0100_0000_01b3);
}
format!("{h1:016x}{h2:016x}")
}
/// Import SVG bytes as a new frame under `parent` at `(x, y)`. Returns the frame id.
pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<NodeId, ImportError> {
let opts = usvg::Options::default();
let tree = usvg::Tree::from_data(data, &opts).map_err(|e| ImportError(e.to_string()))?;
let size = tree.size();
let frame = doc
.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height()))
.map_err(|e| ImportError(e.to_string()))?;
if let Some(f) = doc.node_mut(frame) {
f.fills.clear();
}
let mut cx = Ctx { doc, count: 0 };
add_group(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
Ok(frame)
}

356
crates/svg/src/lib.rs Normal file
View File

@ -0,0 +1,356 @@
//! SVG export and import.
//!
//! Export writes one `<svg>` sized to the exported nodes' bounds. Shapes become `<path>`s with
//! their fills (solid, linear/radial gradients; angular and diamond fall back to their first
//! colour... or a raster pattern), strokes are written as filled outlines so Inside/Outside
//! alignment survives exactly, text is outlined, images are embedded as data URIs, and shadows
//! and blurs become SVG filters.
//!
//! Import (via usvg) turns an SVG file into a frame of vector nodes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod import;
pub use import::{import_svg, ImportError};
use std::fmt::Write as _;
use base64::Engine as _;
use figcraft_color::{Color, GradientKind, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind};
use figcraft_geom::{Affine, BezPath, Rect};
#[derive(Clone, Debug)]
pub struct SvgOptions {
/// Include node ids as `id` attributes (Figma's "Include id attribute").
pub include_ids: bool,
/// Outline text (always true for now: no font embedding).
pub outline_text: bool,
}
impl Default for SvgOptions {
fn default() -> Self {
Self { include_ids: false, outline_text: true }
}
}
struct W<'a> {
doc: &'a Document,
defs: String,
body: String,
next: u32,
opts: &'a SvgOptions,
}
fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let r = (v * 1000.0).round() / 1000.0;
let s = format!("{r}");
if s == "-0" { "0".into() } else { s }
}
fn path_d(p: &BezPath) -> String {
use figcraft_geom::kurbo::PathEl::*;
let mut s = String::new();
for el in p.elements() {
match *el {
MoveTo(a) => {
let _ = write!(s, "M{} {}", num(a.x), num(a.y));
}
LineTo(a) => {
let _ = write!(s, "L{} {}", num(a.x), num(a.y));
}
QuadTo(a, b) => {
let _ = write!(s, "Q{} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y));
}
CurveTo(a, b, c) => {
let _ = write!(s, "C{} {} {} {} {} {}", num(a.x), num(a.y), num(b.x), num(b.y), num(c.x), num(c.y));
}
ClosePath => s.push('Z'),
}
}
s
}
fn matrix(a: Affine) -> String {
let c = a.as_coeffs();
format!("matrix({} {} {} {} {} {})", num(c[0]), num(c[1]), num(c[2]), num(c[3]), num(c[4]), num(c[5]))
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn hex(c: Color) -> String {
format!("#{}", c.to_hex())
}
impl W<'_> {
fn id(&mut self, prefix: &str) -> String {
self.next += 1;
format!("{prefix}{}", self.next)
}
/// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing.
fn paint_attr(&mut self, p: &Paint, w: f64, h: f64) -> Option<String> {
if !p.visible || p.opacity <= 0.0 {
return None;
}
let blend = if matches!(p.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
String::new()
} else {
format!(" style=\"mix-blend-mode:{}\"", p.blend.css())
};
match &p.kind {
PaintKind::Solid { color } => {
let a = color.a * p.opacity;
let op = if a < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(a))) } else { String::new() };
Some(format!("fill=\"{}\"{op}{blend}", hex(*color)))
}
PaintKind::Gradient(g) => {
let gid = self.id("paint");
let stops: String = g
.sorted_stops()
.iter()
.map(|s| {
let a = s.color.a * p.opacity;
let op = if a < 0.999 { format!(" stop-opacity=\"{}\"", num(f64::from(a))) } else { String::new() };
format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", num(f64::from(s.position)), hex(s.color))
})
.collect();
let (f, t, k) = ((g.from.0 * w, g.from.1 * h), (g.to.0 * w, g.to.1 * h), (g.width.0 * w, g.width.1 * h));
match g.kind {
GradientKind::Linear => {
let _ = write!(
self.defs,
"<linearGradient id=\"{gid}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">{stops}</linearGradient>",
num(f.0),
num(f.1),
num(t.0),
num(t.1)
);
}
_ => {
// Unit circle mapped onto the handle ellipse.
let m = Affine::new([t.0 - f.0, t.1 - f.1, k.0 - f.0, k.1 - f.1, f.0, f.1]);
let _ = write!(
self.defs,
"<radialGradient id=\"{gid}\" cx=\"0\" cy=\"0\" r=\"1\" gradientUnits=\"userSpaceOnUse\" gradientTransform=\"{}\">{stops}</radialGradient>",
matrix(m)
);
}
}
Some(format!("fill=\"url(#{gid})\"{blend}"))
}
PaintKind::Image(img) => {
let data = self.doc.images.get(&img.image)?;
let pid = self.id("pattern");
let iid = self.id("image");
let mime = sniff_mime(&data.bytes);
let b64 = base64::engine::general_purpose::STANDARD.encode(data.bytes.as_slice());
let t = figcraft_render::paint::image_transform(img, f64::from(data.width), f64::from(data.height), w, h);
let m = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
let (pw, ph) = if img.scale_mode == figcraft_color::ImageScaleMode::Tile {
(f64::from(data.width), f64::from(data.height))
} else {
(f64::from(data.width) * 4.0, f64::from(data.height) * 4.0)
};
let _ = write!(
self.defs,
"<pattern id=\"{pid}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\"><image id=\"{iid}\" width=\"{}\" height=\"{}\" href=\"data:{mime};base64,{b64}\"/></pattern>",
num(pw),
num(ph),
matrix(m),
data.width,
data.height
);
let op = if p.opacity < 0.999 { format!(" fill-opacity=\"{}\"", num(f64::from(p.opacity))) } else { String::new() };
Some(format!("fill=\"url(#{pid})\"{op}{blend}"))
}
}
}
fn filter_for(&mut self, n: &Node) -> Option<String> {
let fx: Vec<&figcraft_doc::Effect> = n.effects.iter().filter(|e| e.visible).collect();
if fx.is_empty() {
return None;
}
let fid = self.id("filter");
let mut f = format!("<filter id=\"{fid}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" color-interpolation-filters=\"sRGB\">");
let mut merge = Vec::new();
for (i, e) in fx.iter().enumerate() {
match &e.kind {
EffectKind::DropShadow(s) => {
let _ = write!(
f,
"<feMorphology in=\"SourceAlpha\" operator=\"{}\" radius=\"{}\" result=\"s{i}m\"/><feOffset in=\"s{i}m\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>",
if s.spread >= 0.0 { "dilate" } else { "erode" },
num(s.spread.abs()),
num(s.offset.0),
num(s.offset.1),
num(s.radius / 2.0),
num(f64::from(s.color.r)),
num(f64::from(s.color.g)),
num(f64::from(s.color.b)),
num(f64::from(s.color.a))
);
merge.push(format!("s{i}"));
}
EffectKind::LayerBlur { radius } => {
let _ = write!(f, "<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\" result=\"blur\"/>", num(radius / 2.0));
}
_ => {}
}
}
let has_blur = fx.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur { .. }));
f.push_str("<feMerge>");
for m in &merge {
let _ = write!(f, "<feMergeNode in=\"{m}\"/>");
}
let _ = write!(f, "<feMergeNode in=\"{}\"/></feMerge></filter>", if has_blur { "blur" } else { "SourceGraphic" });
self.defs.push_str(&f);
Some(fid)
}
fn node(&mut self, id: NodeId, xf: Affine, depth: usize) {
if depth > 256 {
return;
}
let Some(n) = self.doc.node(id) else { return };
if !n.visible || n.is_mask {
return;
}
let xf = xf * n.transform;
let mut attrs = String::new();
if self.opts.include_ids {
let _ = write!(attrs, " id=\"{}\"", esc(&n.name.replace(' ', "_")));
}
if n.opacity < 0.999 {
let _ = write!(attrs, " opacity=\"{}\"", num(f64::from(n.opacity)));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
let _ = write!(attrs, " style=\"mix-blend-mode:{}\"", n.blend.css());
}
if let Some(fid) = self.filter_for(n) {
let _ = write!(attrs, " filter=\"url(#{fid})\"");
}
let _ = write!(self.body, "<g{attrs}>");
let (w, h) = (n.width, n.height);
let outline = match &n.kind {
NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_render::node_outline(self.doc, n),
};
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
if let Some((path, rule)) = &outline
&& closed
{
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.fills {
if let Some(a) = self.paint_attr(p, w, h) {
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
}
}
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
let clip = n.clip && n.kind.is_frame_like();
if clip && let Some((p, _)) = &outline {
let cid = self.id("clip");
let mut cp = p.clone();
cp.apply_affine(xf);
let _ = write!(self.defs, "<clipPath id=\"{cid}\"><path d=\"{}\"/></clipPath>", path_d(&cp));
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
}
let kids = n.children.clone();
let mut i = 0;
while i < kids.len() {
let Some(&c) = kids.get(i) else { break };
if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
let mid = self.id("mask");
let saved = std::mem::take(&mut self.body);
if let Some(mn) = self.doc.node(c) {
let mxf = xf * mn.transform;
if let Some((mp, _)) = figcraft_render::node_outline(self.doc, mn) {
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" fill=\"white\"/>", matrix(mxf), path_d(&mp));
}
}
let mask_body = std::mem::replace(&mut self.body, saved);
let _ = write!(self.defs, "<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\">{mask_body}</mask>");
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
let mut j = i + 1;
while let Some(&k) = kids.get(j) {
if self.doc.node(k).is_some_and(|kn| kn.is_mask) {
break;
}
self.node(k, xf, depth + 1);
j += 1;
}
self.body.push_str("</g>");
i = j;
continue;
}
self.node(c, xf, depth + 1);
i += 1;
}
if clip && outline.is_some() {
self.body.push_str("</g>");
}
}
if n.has_visible_stroke() {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
_ => figcraft_doc::geometry::stroke_geometry(n),
};
if let Some(g) = geom {
let area = figcraft_render::draw::stroke_area(n, &g, closed);
let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.strokes {
if let Some(a) = self.paint_attr(p, w, h) {
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
}
}
}
}
self.body.push_str("</g>");
}
}
fn sniff_mime(b: &[u8]) -> &'static str {
if b.starts_with(&[0x89, b'P', b'N', b'G']) {
"image/png"
} else if b.starts_with(&[0xFF, 0xD8]) {
"image/jpeg"
} else if b.starts_with(b"GIF") {
"image/gif"
} else if b.len() > 12 && b.get(8..12) == Some(b"WEBP") {
"image/webp"
} else {
"application/octet-stream"
}
}
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?;
let mut w = W { doc, defs: String::new(), body: String::new(), next: 0, opts };
let origin = Affine::translate((-bounds.x0, -bounds.y0));
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
w.node(*id, origin * parent_xf, 0);
}
let (bw, bh) = (bounds.width().max(0.0), bounds.height().max(0.0));
let defs = if w.defs.is_empty() { String::new() } else { format!("<defs>{}</defs>", w.defs) };
Some(format!(
"<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">{}{}</svg>\n",
num(bw),
num(bh),
num(bw),
num(bh),
w.body,
defs
))
}
#[cfg(test)]
mod tests;

45
crates/svg/src/tests.rs Normal file
View File

@ -0,0 +1,45 @@
use super::*;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeKind};
#[test]
fn export_basic_shapes() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Card", 0.0, 0.0, 200.0, 100.0).unwrap();
let r = d.create(f, NodeKind::Rectangle, "R", 10.0, 10.0, 50.0, 50.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
d.get_mut(r).unwrap().effects.push(Effect::drop_shadow());
let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap();
assert!(svg.starts_with("<svg width=\"200\" height=\"100\""));
assert!(svg.contains("#FF0000"));
assert!(svg.contains("<filter"));
assert!(svg.contains("clip-path"));
// Round-trip through import.
let mut d2 = Document::new("t2");
let p2 = d2.first_page().unwrap();
let frame = import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "Imported").unwrap();
assert!(!d2.subtree(frame).is_empty());
assert!(d2.validate().is_empty());
}
#[test]
fn import_rejects_garbage() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
}
#[test]
fn import_gradient_and_groups() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
<defs><linearGradient id="g"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs>
<g id="grp"><rect x="10" y="10" width="30" height="30" fill="url(#g)" stroke="#000" stroke-width="2"/></g>
<circle cx="70" cy="25" r="20" fill="#0f0" fill-opacity="0.5"/></svg>"##;
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = import_svg(&mut d, svg, p, 0.0, 0.0, "x").unwrap();
let kinds: Vec<&str> = d.subtree(f).iter().map(|i| d.node(*i).unwrap().kind.type_name()).collect();
assert!(kinds.contains(&"GROUP"));
assert!(kinds.iter().filter(|k| **k == "VECTOR").count() >= 2);
}