mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
M2.1: migrate FigCraft to egui 0.37 and reconcile current main
This commit is contained in:
@@ -25,7 +25,7 @@ jobs:
|
||||
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
|
||||
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
|
||||
# once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
|
||||
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
|
||||
# Re-pin once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web build to Inter.
|
||||
|
||||
Generated
+105
-89
@@ -4,21 +4,21 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.24.1"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"serde",
|
||||
@@ -27,43 +27,37 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.35.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
|
||||
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
name = "accesskit_ios"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.3",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-ui-kit 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.26.3"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.3",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -71,9 +65,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
@@ -89,13 +83,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.32.1"
|
||||
version = "0.35.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
|
||||
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"static_assertions",
|
||||
"windows",
|
||||
"windows-core",
|
||||
@@ -103,11 +97,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.32.2"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
|
||||
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_ios",
|
||||
"accesskit_macos",
|
||||
"accesskit_unix",
|
||||
"accesskit_windows",
|
||||
@@ -907,9 +902,8 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
@@ -1125,9 +1119,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
@@ -1135,9 +1128,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1159,6 +1151,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"send_wrapper",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1170,9 +1163,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
@@ -1190,9 +1182,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1210,30 +1201,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
@@ -1245,9 +1239,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1265,9 +1258,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -1292,22 +1284,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "2.7.3"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "0.17.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1356,9 +1348,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1374,16 +1365,17 @@ dependencies = [
|
||||
"self_cell",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
"unicode-segmentation",
|
||||
"vello_cpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -2061,9 +2053,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
@@ -2613,8 +2605,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3365,9 +3356,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
@@ -3376,9 +3367,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
@@ -3386,9 +3377,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -3399,9 +3390,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -4072,6 +4063,12 @@ version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -4877,9 +4874,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
@@ -4892,9 +4889,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
|
||||
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo",
|
||||
@@ -5694,8 +5691,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
@@ -6191,3 +6187,23 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_kittest"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
+30
-5
@@ -35,8 +35,11 @@ figcraft-plugin = { path = "crates/plugin" }
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version). The rev is the last merged one; the released 0.37 pin is
|
||||
# supplied by the migration's reviewed craft-libs revision.
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -79,9 +82,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
|
||||
wat = "1.261"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
webbrowser = "1"
|
||||
@@ -128,3 +131,25 @@ codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -141,6 +141,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
|
||||
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
|
||||
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
|
||||
- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
@@ -377,6 +378,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
|
||||
| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
|
||||
| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
|
||||
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct Args {
|
||||
impl Args {
|
||||
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
|
||||
let mut a = Args::default();
|
||||
// The first bad option, reported once the rest of the line is checked for `--help`.
|
||||
let mut bad: Option<CliError> = None;
|
||||
let mut it = argv.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let name = if let Some(n) = arg.strip_prefix("--") {
|
||||
@@ -30,19 +32,27 @@ impl Args {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (name, None),
|
||||
};
|
||||
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
|
||||
if inline.is_none() && matches!(name, "help" | "h") {
|
||||
return Err(CliError::Help);
|
||||
}
|
||||
if valued.contains(&name) {
|
||||
let v = match inline {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
match inline.or_else(|| it.next().cloned()) {
|
||||
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
|
||||
None => {
|
||||
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
|
||||
}
|
||||
}
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
return Err(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
match bad {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(a),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
@@ -135,4 +145,18 @@ mod tests {
|
||||
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
|
||||
assert_eq!(a.positional, ["-5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_asked_for_anywhere_but_as_a_value() {
|
||||
// Also after a bad option: help wins over the usage error (#1161 review).
|
||||
for argv in
|
||||
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
|
||||
{
|
||||
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
|
||||
}
|
||||
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
|
||||
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
|
||||
// `--help=x` is no request for help, just an unknown option.
|
||||
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The file subcommands: info, export, convert, render and run.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
@@ -207,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
|
||||
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
|
||||
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
|
||||
fn sanitize(name: &str) -> String {
|
||||
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||||
let s = s.trim().trim_matches('.').to_string();
|
||||
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
|
||||
let s = figcraft_engine::cmd::file::safe_file_name(name);
|
||||
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
|
||||
}
|
||||
|
||||
/// What `export` exports and how.
|
||||
@@ -411,6 +415,14 @@ mod tests {
|
||||
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
|
||||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
|
||||
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
|
||||
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
|
||||
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
|
||||
assert_eq!(sanitize(" .hidden. "), "hidden");
|
||||
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
|
||||
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
|
||||
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,7 @@ USAGE:
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
@@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
|
||||
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
|
||||
as the user who started it, as before. A running app confines itself: start it with
|
||||
`figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
|
||||
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
|
||||
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
|
||||
confined by the app it attaches to, not by flags passed to the CLI: start that app with
|
||||
`figcraft --control PORT` and the same roots (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
@@ -74,6 +75,10 @@ fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(CliError::Usage(msg)) => {
|
||||
eprintln!("error: {msg}\n\n{USAGE}");
|
||||
ExitCode::from(2)
|
||||
@@ -87,6 +92,8 @@ fn main() -> ExitCode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
|
||||
Help,
|
||||
/// Bad arguments: usage is printed, exit code 2.
|
||||
Usage(String),
|
||||
/// The operation failed: exit code 1.
|
||||
|
||||
@@ -39,6 +39,40 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
|
||||
/// (#1155), whatever else is on the line; nothing runs.
|
||||
#[test]
|
||||
fn help_after_a_subcommand_prints_the_usage() {
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["-h"],
|
||||
&["help"],
|
||||
&["mcp", "--help"],
|
||||
&["mcp", "-h"],
|
||||
&["info", "--help"],
|
||||
&["export", "--help"],
|
||||
&["export", "x.figcraft", "-o", "out", "--help"],
|
||||
&["convert", "-h"],
|
||||
&["render", "--help"],
|
||||
&["run", "--help"],
|
||||
&["commands", "--help"],
|
||||
&["plugin", "publish", "--help"],
|
||||
&["perf", "--help"],
|
||||
&["mcp", "--attach", "notaport", "--help"],
|
||||
&["mcp", "--bogus", "--help"],
|
||||
] {
|
||||
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
// A value that happens to be `-h` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_info_convert() {
|
||||
let d = tmp("run");
|
||||
@@ -80,6 +114,30 @@ fn run_export_info_convert() {
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
|
||||
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
|
||||
#[test]
|
||||
fn export_file_names_keep_what_the_app_keeps() {
|
||||
let d = tmp("names");
|
||||
let script = d.join("s.json");
|
||||
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
|
||||
let steps: Vec<serde_json::Value> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
|
||||
.collect();
|
||||
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
|
||||
let dir = d.join("out");
|
||||
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
|
||||
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
|
||||
}
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn args(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|a| a.to_string()).collect()
|
||||
s.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
|
||||
//! first damaged record (a write cut short).
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||
|
||||
/// `path` with `.tmp` added: where its next version is written first.
|
||||
pub fn temp_of(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
@@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() {
|
||||
assert_eq!(served(fetch(&mut store)), (2, v2));
|
||||
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_publish_cannot_claim_the_highest_version() {
|
||||
let dir = TempDir::new("libmax");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("component.create", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone();
|
||||
let mut archive = |version: u64| {
|
||||
lib.manifest.version = version;
|
||||
figcraft_engine::library_server::archive(&lib).unwrap()
|
||||
};
|
||||
let high = 1u64 << 32;
|
||||
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it");
|
||||
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in");
|
||||
}
|
||||
|
||||
@@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() {
|
||||
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_per_address_count_the_wider_ipv6_blocks() {
|
||||
let dir = TempDir::new("block-caps");
|
||||
let mut c = open(dir.path());
|
||||
c.max_files_per_ip = 1;
|
||||
c.publishes_per_hour = 1000;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
|
||||
for i in 0..4 {
|
||||
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
|
||||
}
|
||||
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
|
||||
assert!(e.contains("files on this server already"), "{e}");
|
||||
// A /56 keeps twice one address's files.
|
||||
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
|
||||
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
|
||||
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
|
||||
assert!(e.contains("files on this server already"), "{e}");
|
||||
drop(store);
|
||||
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
|
||||
let probe = TempDir::new("block-caps-probe");
|
||||
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
|
||||
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
|
||||
let dir = TempDir::new("block-caps-bytes");
|
||||
let mut s = open(dir.path()).store();
|
||||
s.hosting.max_files_per_ip = 100;
|
||||
s.hosting.max_bytes_per_ip = size;
|
||||
let mut store = Store::open(dir.path(), s).unwrap();
|
||||
for i in 0..4 {
|
||||
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
|
||||
}
|
||||
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
|
||||
assert!(e.contains("MiB it may keep"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_count_the_wider_ipv6_blocks_too() {
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
let probe = TempDir::new("block-room-probe");
|
||||
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
|
||||
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
|
||||
let dir = TempDir::new("block-room");
|
||||
let mut s = open(dir.path()).store();
|
||||
s.hosting.max_files_per_ip = 100;
|
||||
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
|
||||
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
|
||||
let mut store = Store::open(dir.path(), s).unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..4 {
|
||||
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
|
||||
}
|
||||
let server = Backend::open(&mut store, &ids[0]).unwrap();
|
||||
// Its own /64 has room for this, but its /56 doesn't.
|
||||
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
|
||||
assert!(e.contains("have reached their"), "{e}");
|
||||
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_files_published_without_the_key_expire() {
|
||||
let dir = TempDir::new("expiry");
|
||||
@@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() {
|
||||
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_commands_do_not_sweep_idle_files() {
|
||||
let dir = TempDir::new("offline-sweep");
|
||||
let mut c = open(dir.path());
|
||||
c.idle_days = 30.0;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
|
||||
store.age(&public.id, 31 * 86_400);
|
||||
drop(store);
|
||||
let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap();
|
||||
drop(store);
|
||||
assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file");
|
||||
let _serving = Store::open(dir.path(), c.store()).unwrap();
|
||||
assert!(!dir.path().join("files").join(&public.id).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_blocks_refuse_with_a_reason() {
|
||||
let dir = TempDir::new("limits");
|
||||
|
||||
@@ -42,7 +42,7 @@ use figcraft_collab::message::{
|
||||
PluginReply, PluginReq, ReportInfo, Usage,
|
||||
};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
|
||||
use figcraft_collab_net::token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool {
|
||||
|
||||
/// A name others may see: trimmed, no control characters, at most 256 characters.
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
|
||||
s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect()
|
||||
}
|
||||
|
||||
/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters.
|
||||
fn clean_reason(s: &str) -> String {
|
||||
let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect();
|
||||
s.trim().chars().take(MAX_REASON).collect()
|
||||
}
|
||||
|
||||
/// Bidirectional format characters, which could reorder what a terminal shows.
|
||||
fn is_bidi_control(c: char) -> bool {
|
||||
matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
||||
}
|
||||
|
||||
/// Take the data directory's lock: `None` when someone else holds it.
|
||||
@@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result<Option<File>, String> {
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700);
|
||||
dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
|
||||
// `DirBuilder` only applies the mode to directories it creates; tighten an existing one too.
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
||||
.map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?;
|
||||
let f = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
@@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result<String, String> {
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The highest version a file's first library publish may claim. Publishing counts up by one (a
|
||||
/// library carried over from another server keeps its number), so no real history gets near it.
|
||||
/// Later publishes may go one past the version on the server, so there is always a next one.
|
||||
const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32;
|
||||
|
||||
impl Store {
|
||||
/// Use the data directory `root` (made if missing). Fails when another server or command holds
|
||||
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
|
||||
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
|
||||
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
|
||||
let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?;
|
||||
store.sweep();
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// [`open`](Self::open), or `None` when someone else holds the directory.
|
||||
/// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or
|
||||
/// `None` when someone else holds the directory.
|
||||
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
|
||||
let Some(lock) = lock(root)? else { return Ok(None) };
|
||||
for name in [KEY, BLOCKED, REPORTS] {
|
||||
let _ = fs::remove_file(disk::temp_of(&root.join(name)));
|
||||
}
|
||||
let key = load_key(root)?;
|
||||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
@@ -374,7 +400,6 @@ impl Store {
|
||||
store.scan(&dir);
|
||||
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
|
||||
store.reports = store.read_list(REPORTS);
|
||||
store.sweep();
|
||||
Ok(Some(store))
|
||||
}
|
||||
|
||||
@@ -425,12 +450,20 @@ impl Store {
|
||||
/// A file folder's description and links, after checking its snapshot and log. Leftover
|
||||
/// temporary files (writes cut short) are removed.
|
||||
fn check(&self, path: &Path) -> Result<Entry, String> {
|
||||
for name in [SNAPSHOT, LOG, META, LINKS] {
|
||||
for name in [SNAPSHOT, LOG, META, LINKS, USAGE] {
|
||||
let tmp = disk::temp_of(&path.join(name));
|
||||
if tmp.exists() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
if let Ok(entries) = fs::read_dir(path) {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with("library-") && name.ends_with(".bin.tmp") {
|
||||
let _ = fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
@@ -514,6 +547,34 @@ impl Store {
|
||||
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
|
||||
}
|
||||
|
||||
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
|
||||
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
|
||||
/// address that doesn't parse counts as itself only, ×1, by its exact text.
|
||||
fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
|
||||
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
|
||||
let kept: Vec<(&str, Vec<_>, u64)> = self
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|(id, e)| {
|
||||
let from = e.meta.ip.as_deref()?;
|
||||
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
|
||||
})
|
||||
.collect();
|
||||
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
|
||||
let blocks = blocks_of(ip);
|
||||
if blocks.is_empty() {
|
||||
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
|
||||
return vec![(1, files, bytes)];
|
||||
}
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|(block, times)| {
|
||||
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
|
||||
(times, files, bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep a document published without the key from `ip`, within the caps on what one address
|
||||
/// and everyone may keep: the file, its links and its owner token.
|
||||
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
|
||||
@@ -525,19 +586,21 @@ impl Store {
|
||||
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
|
||||
return Err("too many files published from your address lately: try again in an hour".into());
|
||||
}
|
||||
let ip_text = ip.to_string();
|
||||
let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect();
|
||||
if mine.len() >= max_files {
|
||||
return Err(format!(
|
||||
"your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires",
|
||||
mine.len()
|
||||
));
|
||||
let blocks = self.per_block(&ip.to_string());
|
||||
for &(times, files, _) in &blocks {
|
||||
if files >= max_files.saturating_mul(times as usize) {
|
||||
return Err(format!(
|
||||
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
|
||||
));
|
||||
}
|
||||
}
|
||||
let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum();
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
let fits = |n: u64| {
|
||||
if used_ip.saturating_add(n) > max_ip {
|
||||
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20));
|
||||
for &(times, _, used) in &blocks {
|
||||
let cap = max_ip.saturating_mul(u64::from(times));
|
||||
if used.saturating_add(n) > cap {
|
||||
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
|
||||
}
|
||||
}
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
|
||||
@@ -748,7 +811,7 @@ impl Store {
|
||||
|
||||
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
|
||||
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
|
||||
let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect();
|
||||
let reason = clean_reason(reason);
|
||||
if reason.is_empty() {
|
||||
return Err("say what is wrong with the file".into());
|
||||
}
|
||||
@@ -973,6 +1036,12 @@ impl Store {
|
||||
if m.version <= old.version {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
// A jump would let a client use up the numbers and leave no newer version to publish.
|
||||
if m.version > old.version.saturating_add(1) {
|
||||
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
|
||||
}
|
||||
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
|
||||
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
|
||||
}
|
||||
// Each version has a file of its own, written before the description names it: a failed
|
||||
// description write or a crash in between leaves the previous version served whole, never
|
||||
@@ -1090,9 +1159,11 @@ impl Store {
|
||||
let h = &self.cfg.hosting;
|
||||
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
|
||||
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
|
||||
let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum();
|
||||
if used_ip.saturating_add(n) > max_ip {
|
||||
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20));
|
||||
for (times, _, used) in self.per_block(&ip) {
|
||||
let cap = max_ip.saturating_mul(u64::from(times));
|
||||
if used.saturating_add(n) > cap {
|
||||
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
|
||||
}
|
||||
}
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
@@ -1110,7 +1181,24 @@ impl Store {
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
let mut kept = 0;
|
||||
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
|
||||
let log_path = dir.join(LOG);
|
||||
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
|
||||
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
|
||||
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
|
||||
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
|
||||
r => r,
|
||||
};
|
||||
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
|
||||
let read = match read.map(|b| disk::read_log(&b)) {
|
||||
Ok(Err(why)) => {
|
||||
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
|
||||
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
|
||||
Err(format!("{why}; it was moved aside to {}", aside.display()))
|
||||
}
|
||||
Ok(Ok(log)) => Ok(log),
|
||||
Err(why) => Err(why),
|
||||
};
|
||||
match read {
|
||||
Ok(log) if log.generation == generation => {
|
||||
fresh_log = false;
|
||||
for entry in log.entries {
|
||||
@@ -1167,7 +1255,10 @@ impl Store {
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
|
||||
_ => {
|
||||
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
|
||||
self.rewrite(file, next, &server).map_err(|e| e.why)?
|
||||
}
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
@@ -1243,7 +1334,11 @@ impl Store {
|
||||
fn compact(&mut self, file: &str, server: &Server) {
|
||||
self.compactions += 1;
|
||||
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
|
||||
match self.rewrite(file, generation + 1, server) {
|
||||
let Some(next) = generation.checked_add(1) else {
|
||||
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
|
||||
return;
|
||||
};
|
||||
match self.rewrite(file, next, server) {
|
||||
Ok(log) => {
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.log = Some(log);
|
||||
|
||||
@@ -229,6 +229,17 @@ fn one_server_per_data_directory() {
|
||||
assert_eq!(again.key(), key, "the key is kept");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_data_directory_is_made_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new("perms");
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(crate::store::lock(dir.path()).unwrap().is_some());
|
||||
let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_directories_are_removed() {
|
||||
let path = {
|
||||
@@ -313,3 +324,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
|
||||
let name = page_name(&restored);
|
||||
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_that_cant_be_read_is_never_overwritten() {
|
||||
let dir = TempDir::new("unreadlog");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let log_path = dir.path().join(FILES);
|
||||
let last = "Confirmed page name number 39 with some padding";
|
||||
let id = {
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
for i in 0..40 {
|
||||
rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding"));
|
||||
store.changed(&id, &mut server);
|
||||
}
|
||||
assert!(store.flush().is_empty());
|
||||
id
|
||||
};
|
||||
let log_path = log_path.join(&id).join(LOG);
|
||||
let before = fs::read(&log_path).unwrap();
|
||||
// The cap lowered just under the log: it can't be read now, but it must still be there after.
|
||||
let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() };
|
||||
{
|
||||
let mut store = Store::open(dir.path(), lowered).unwrap();
|
||||
let opened = store.open(&id);
|
||||
assert!(opened.is_err(), "the file isn't opened without its log");
|
||||
assert!(!store.problems().is_empty(), "the problem is reported");
|
||||
assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was");
|
||||
}
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let server = store.open(&id).unwrap();
|
||||
assert_eq!(page_name(&server), last, "every confirmed edit comes back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_that_isnt_one_is_moved_aside() {
|
||||
let dir = TempDir::new("badlog");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let folder = dir.path().join(FILES).join(&id);
|
||||
let junk = b"not a log at all, but maybe worth keeping".to_vec();
|
||||
fs::write(folder.join(LOG), &junk).unwrap();
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
store.open(&id).unwrap();
|
||||
assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems());
|
||||
let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk));
|
||||
assert!(kept, "the old log's bytes are kept beside the file");
|
||||
}
|
||||
|
||||
const BIDI: [char; 12] =
|
||||
['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
|
||||
|
||||
#[test]
|
||||
fn names_and_report_reasons_lose_bidi_controls() {
|
||||
let dir = TempDir::new("bidi");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
for (n, m) in BIDI.into_iter().enumerate() {
|
||||
let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap();
|
||||
let (id, _, _) = publish(&mut store, &format!("a{m}b"));
|
||||
assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32);
|
||||
store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap();
|
||||
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
|
||||
store.remove(&id).unwrap();
|
||||
}
|
||||
// A reason that is only control characters says nothing.
|
||||
let (id, _, _) = publish(&mut store, "blank");
|
||||
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
|
||||
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maxed_out_generation_does_not_wrap() {
|
||||
let dir = TempDir::new("maxgen");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Max");
|
||||
let path = dir.path().join(FILES).join(&id);
|
||||
let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
|
||||
// Loading: no log goes with the snapshot, so the next generation would overflow.
|
||||
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
|
||||
fs::remove_file(path.join(LOG)).unwrap();
|
||||
assert!(store.open(&id).is_err());
|
||||
// Compacting: the open log is at the largest generation.
|
||||
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
|
||||
let _ = fs::remove_file(path.join(LOG));
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
for i in 0..4 {
|
||||
rename(&mut server, &mut a, &format!("P{i}"));
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
}
|
||||
let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(snap, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_leftover_temporary_file_is_removed_at_startup() {
|
||||
let dir = TempDir::new("leftovers");
|
||||
let id = {
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let id = publish(&mut store, "A").0;
|
||||
store.flush();
|
||||
id
|
||||
};
|
||||
let folder = dir.path().join(FILES).join(&id);
|
||||
let leftovers = [
|
||||
dir.path().join("blocked.json.tmp"),
|
||||
dir.path().join("reports.json.tmp"),
|
||||
dir.path().join("server.key.tmp"),
|
||||
folder.join("usage.json.tmp"),
|
||||
folder.join("library-1.bin.tmp"),
|
||||
];
|
||||
for path in &leftovers {
|
||||
fs::write(path, b"half").unwrap();
|
||||
}
|
||||
let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
for path in &leftovers {
|
||||
assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,12 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
|
||||
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
|
||||
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.fig.ui(ui);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
|
||||
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::args::{Args, file_url_path, is_invite, parse};
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
a.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
@@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
@@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
|
||||
@@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
|
||||
/// does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
|
||||
@@ -71,7 +71,9 @@ impl eframe::App for App {
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
|
||||
// It runs on logic-only callbacks too, so it never reads UI input.
|
||||
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
@@ -86,8 +88,14 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
|
||||
// Recovery deadlines use the same monotonic clock as the app's background work: a
|
||||
// logic-only tick leaves `ctx.input(i.time)` stale.
|
||||
if let Some(wait) = with_app(|fig| {
|
||||
let now = fig.now_secs();
|
||||
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +71,8 @@ sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() {
|
||||
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
|
||||
assert!(hub.barred.contains_key("other"), "another file's stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{Conn, Kind, Line, Out};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server_side, _) = listener.accept().unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
// Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone.
|
||||
let _writer = server_side.try_clone().unwrap();
|
||||
let (tx, _rx) = sync_channel(2);
|
||||
while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {}
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
max_block: usize::MAX,
|
||||
max_all: usize::MAX,
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let mut hub = Hub {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
hub.kick(&[1], "kicked");
|
||||
assert!(hub.conns.is_empty());
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)");
|
||||
}
|
||||
|
||||
@@ -402,6 +402,8 @@ enum HubIn {
|
||||
sock: TcpStream,
|
||||
/// Its size (in flight until handled).
|
||||
bytes: usize,
|
||||
/// Told when the hub takes the connection in (dropped unsent when it doesn't).
|
||||
admitted: SyncSender<()>,
|
||||
},
|
||||
Msg {
|
||||
conn: u64,
|
||||
@@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option<Bytes> {
|
||||
|
||||
/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps).
|
||||
fn refuse(out: &Line, why: &str) {
|
||||
refused(out, why);
|
||||
}
|
||||
|
||||
/// [`refuse`], and whether the close was queued (it isn't when the queue is full).
|
||||
fn refused(out: &Line, why: &str) -> bool {
|
||||
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
|
||||
let _ = out.tx.try_send(Out::Bytes(b, None));
|
||||
}
|
||||
let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why)));
|
||||
out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok()
|
||||
}
|
||||
|
||||
/// What the server tells a client when it is too busy to take more from it.
|
||||
const BUSY: &str = "the server is busy: try again in a moment";
|
||||
|
||||
/// Read the next binary message (skipping pings) and its size, or why the connection should end.
|
||||
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(ClientMsg, usize)>, String> {
|
||||
/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
|
||||
fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, String> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => {
|
||||
@@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(Client
|
||||
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
|
||||
}
|
||||
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
|
||||
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
|
||||
// a message trickled in is cut off by it (`Duplex::read_until`).
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) =>
|
||||
{
|
||||
Err("no hello in time".into())
|
||||
}
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
|
||||
Ok(Message::Close(_)) => Ok(None),
|
||||
Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)),
|
||||
@@ -851,6 +865,7 @@ fn too_large(max: usize) -> String {
|
||||
/// One connection's reader (the writer runs on its own thread).
|
||||
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, open: Open, shared: &Shared) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let hello_deadline = Instant::now() + limits.hello_timeout;
|
||||
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
}
|
||||
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
|
||||
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
|
||||
let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return };
|
||||
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
|
||||
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
|
||||
rd.read_until(Some(hello_deadline));
|
||||
let cfg = config(limits.max_client_message);
|
||||
let mut forwarded = None;
|
||||
// The handshake callback's type is tungstenite's (its error is an HTTP response).
|
||||
@@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
log::info!("event=refused ip={ip} reason=connections_per_address");
|
||||
end(Some("too many connections from your address: close some and try again"));
|
||||
} else {
|
||||
match next_message(&mut ws, limits.max_hello) {
|
||||
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
|
||||
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
|
||||
ws.get_mut().read_until(None);
|
||||
match hello {
|
||||
Ok(Some((
|
||||
first @ (ClientMsg::Hello(_)
|
||||
| ClientMsg::Manage(_)
|
||||
@@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
|
||||
end(Some(BUSY));
|
||||
} else {
|
||||
ws.set_config(|c| *c = config(max));
|
||||
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok());
|
||||
if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
|
||||
let (admitted, verdict) = sync_channel(1);
|
||||
let opened =
|
||||
sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok());
|
||||
// Nothing more is read until the hub took the connection in (its token checked):
|
||||
// only then may it send messages of the full size.
|
||||
if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
|
||||
ws.set_config(|c| *c = config(max));
|
||||
read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end);
|
||||
let _ = hub.send(HubIn::Gone { conn });
|
||||
}
|
||||
@@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
drop(counted);
|
||||
// The block's counters go with the last handle on them (the table forgets it then).
|
||||
drop(block);
|
||||
ws.get_mut().read_until(None);
|
||||
linger(&mut ws);
|
||||
drop(out);
|
||||
// The writer closes the socket once it has sent what's queued (a refusal, a close).
|
||||
@@ -981,7 +1006,7 @@ fn read_loop(
|
||||
let mut bucket = Bucket::new(limits.rate, limits.burst);
|
||||
let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST);
|
||||
loop {
|
||||
match next_message(ws, max) {
|
||||
match next_message(ws, max, None) {
|
||||
Ok(Some((msg, bytes))) => {
|
||||
let now = Instant::now();
|
||||
if !bucket.take(now) {
|
||||
@@ -1074,6 +1099,8 @@ struct Pending {
|
||||
out: Line,
|
||||
sock: TcpStream,
|
||||
ip: IpAddr,
|
||||
/// Told when it is taken in.
|
||||
admitted: SyncSender<()>,
|
||||
}
|
||||
|
||||
/// A connection the hub took in.
|
||||
@@ -1195,7 +1222,7 @@ impl<B: Backend> Hub<B> {
|
||||
|
||||
fn handle(&mut self, m: HubIn) {
|
||||
match m {
|
||||
HubIn::Open { conn, ip, first, out, sock, bytes } => {
|
||||
HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => {
|
||||
self.in_flight.give(bytes);
|
||||
// The operator (with the server key) gets in from anywhere; everyone else is
|
||||
// checked (blocked addresses, connection rates).
|
||||
@@ -1204,7 +1231,7 @@ impl<B: Backend> Hub<B> {
|
||||
log::info!("event=refused ip={ip} reason={why:?}");
|
||||
return refuse(&out, &why);
|
||||
}
|
||||
let c = Pending { out, sock, ip };
|
||||
let c = Pending { out, sock, ip, admitted };
|
||||
match first {
|
||||
ClientMsg::Hello(hello) => self.join(conn, hello, c),
|
||||
ClientMsg::Manage(m) => self.manage(conn, &m, c),
|
||||
@@ -1399,6 +1426,7 @@ impl<B: Backend> Hub<B> {
|
||||
if let Some(c) = old.and_then(|old| self.conns.remove(&old)) {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
let _ = c.admitted.try_send(());
|
||||
self.conns
|
||||
.insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip });
|
||||
self.queue(&file, more);
|
||||
@@ -1429,7 +1457,9 @@ impl<B: Backend> Hub<B> {
|
||||
barred.insert(c.ip);
|
||||
}
|
||||
log::info!("event=removed file={file} client={id} ip={}", c.ip);
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
|
||||
if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.gone(conn);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1444,6 +1474,7 @@ impl<B: Backend> Hub<B> {
|
||||
|
||||
/// Take in connection `c` as `kind` and send it `first`.
|
||||
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
|
||||
let _ = c.admitted.try_send(());
|
||||
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
|
||||
self.answer(conn, first);
|
||||
}
|
||||
@@ -1543,8 +1574,10 @@ impl<B: Backend> Hub<B> {
|
||||
// People in the file are told the session is over for them, as when a host stops (they
|
||||
// keep their copy; nothing retries); a library request is refused.
|
||||
for conn in &people {
|
||||
if let Some(c) = self.conns.get(conn) {
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
|
||||
if let Some(c) = self.conns.get(conn)
|
||||
&& c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err()
|
||||
{
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.gone(*conn);
|
||||
}
|
||||
@@ -1593,8 +1626,10 @@ impl<B: Backend> Hub<B> {
|
||||
/// Refuse connections with `why` and drop them at once (nothing more they send counts).
|
||||
fn kick(&mut self, conns: &[u64], why: &str) {
|
||||
for conn in conns {
|
||||
if let Some(c) = self.conns.get(conn) {
|
||||
refuse(&c.out, why);
|
||||
if let Some(c) = self.conns.get(conn)
|
||||
&& !refused(&c.out, why)
|
||||
{
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.gone(*conn);
|
||||
}
|
||||
@@ -1747,3 +1782,7 @@ mod queue_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "barred_tests.rs"]
|
||||
mod barred_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "preauth_tests.rs"]
|
||||
mod preauth_tests;
|
||||
|
||||
@@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence};
|
||||
pub type Waker = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// Most discrete events kept between two drains (joins, leaves, status); the oldest go first.
|
||||
/// Document sync events don't count and are never dropped: the sync client treats a missing
|
||||
/// batch as a fatal sequence gap, so losing one would end the session.
|
||||
const MAX_EVENTS: usize = 10_000;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
events: VecDeque<Event>,
|
||||
/// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order.
|
||||
next: u64,
|
||||
/// [`Event::Sync`] events, never dropped.
|
||||
syncs: VecDeque<(u64, Event)>,
|
||||
/// Every other event, capped at [`MAX_EVENTS`].
|
||||
others: VecDeque<(u64, Event)>,
|
||||
presence: BTreeMap<u32, Presence>,
|
||||
}
|
||||
|
||||
@@ -43,11 +50,17 @@ impl Inbox {
|
||||
if let Event::PeerLeft(id) = &e {
|
||||
s.presence.remove(id);
|
||||
}
|
||||
if s.events.len() >= MAX_EVENTS {
|
||||
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
|
||||
s.events.pop_front();
|
||||
let n = s.next;
|
||||
s.next += 1;
|
||||
if matches!(e, Event::Sync(_)) {
|
||||
s.syncs.push_back((n, e));
|
||||
return;
|
||||
}
|
||||
s.events.push_back(e);
|
||||
if s.others.len() >= MAX_EVENTS {
|
||||
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
|
||||
s.others.pop_front();
|
||||
}
|
||||
s.others.push_back((n, e));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,8 +75,70 @@ impl Inbox {
|
||||
/// peer.
|
||||
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
|
||||
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut out: Vec<Event> = s.events.drain(..).collect();
|
||||
let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable();
|
||||
let mut others = std::mem::take(&mut s.others).into_iter().peekable();
|
||||
let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len());
|
||||
loop {
|
||||
let sync_first = match (syncs.peek(), others.peek()) {
|
||||
(Some((a, _)), Some((b, _))) => a < b,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => break,
|
||||
};
|
||||
let next = if sync_first { syncs.next() } else { others.next() };
|
||||
out.extend(next.map(|(_, e)| e));
|
||||
}
|
||||
out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p)));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use figcraft_collab::message::ServerMsg;
|
||||
|
||||
fn reject(batch_id: u64) -> Event {
|
||||
Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() })
|
||||
}
|
||||
|
||||
fn sync_ids(events: &[Event]) -> Vec<u64> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_inbox_never_drops_sync_events() {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
inbox.push(reject(1));
|
||||
for _ in 0..MAX_EVENTS {
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
}
|
||||
inbox.push(reject(2));
|
||||
for _ in 0..MAX_EVENTS {
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
}
|
||||
let out = inbox.drain();
|
||||
assert_eq!(sync_ids(&out), vec![1, 2]);
|
||||
assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_events_past_the_cap_are_all_kept_in_order() {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let n = MAX_EVENTS as u64 + 5;
|
||||
for id in 0..n {
|
||||
inbox.push(reject(id));
|
||||
}
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
let out = inbox.drain();
|
||||
let ids = sync_ids(&out);
|
||||
assert_eq!(ids.len() as u64, n, "Sync events dropped");
|
||||
assert!(ids.iter().copied().eq(0..n), "Sync events out of order");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
|
||||
//! reconnecting. Every test stops its host and closes its sockets.
|
||||
|
||||
use std::io::Read;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -670,3 +670,49 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
assert!(!welcomed(&gp.events));
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// How long a connection that never says hello stays open while `poke` sends it something every
|
||||
/// 300 ms (told whether it's the first time); `None`: still open after 6 s.
|
||||
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut r = Raw::connect(addr);
|
||||
let t0 = Instant::now();
|
||||
let (mut closed, mut first) = (None, true);
|
||||
while t0.elapsed() < Duration::from_secs(6) {
|
||||
if !poke(&mut r, std::mem::take(&mut first)) {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
// Drain what the host sent back (pongs, the close); an error means it hung up.
|
||||
r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap();
|
||||
match r.0.read() {
|
||||
Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
host.stop();
|
||||
closed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
|
||||
let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok());
|
||||
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() {
|
||||
// A binary message begun and never finished: one masked byte per frame, each in time for the
|
||||
// read timeout.
|
||||
let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok());
|
||||
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}");
|
||||
}
|
||||
|
||||
@@ -206,9 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<W
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
|
||||
/// How long `close` waits for the server's close.
|
||||
const CLOSE_DRAIN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost,
|
||||
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging, or goes quiet, would pin the
|
||||
/// loop).
|
||||
fn close(mut ws: Ws) {
|
||||
let _ = ws.close(None);
|
||||
ws.get_mut().read_until(Some(std::time::Instant::now() + CLOSE_DRAIN));
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
|
||||
@@ -240,3 +246,51 @@ fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whether `close` returns within 3 s against a server that never answers the close and does
|
||||
/// `serve` instead, with reads waiting up to `idle`.
|
||||
fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket<std::net::TcpStream>) + Send + 'static) -> bool {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
if let Ok(ws) = tungstenite::accept(s) {
|
||||
serve(ws);
|
||||
}
|
||||
});
|
||||
let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() };
|
||||
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
|
||||
let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
close(ws);
|
||||
let _ = tx.send(());
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(3)).is_ok()
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers the close must not pin `close` forever.
|
||||
#[test]
|
||||
fn close_returns_against_a_server_that_keeps_pinging() {
|
||||
let pinging = closes_in_time(Duration::from_millis(300), |mut ws| {
|
||||
while ws.send(Message::Ping(Bytes::new())).is_ok() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
});
|
||||
assert!(pinging, "close() kept draining a pinging server");
|
||||
}
|
||||
|
||||
/// Nor one that goes quiet, however long the idle timeout.
|
||||
#[test]
|
||||
fn close_returns_against_a_silent_server() {
|
||||
let silent = closes_in_time(Duration::from_secs(10), |ws| {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
drop(ws);
|
||||
});
|
||||
assert!(silent, "close() waited out the idle timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ pub struct Duplex {
|
||||
tls: Option<Tls>,
|
||||
/// What the bytes read count against (a server's reader), if anything.
|
||||
meter: Option<Meter>,
|
||||
/// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]).
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
|
||||
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
|
||||
let write = Arc::new(Mutex::new(stream.try_clone()?));
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None };
|
||||
Ok((a, b, stream))
|
||||
}
|
||||
|
||||
@@ -49,6 +51,12 @@ impl Duplex {
|
||||
self.meter = Some(meter);
|
||||
}
|
||||
|
||||
/// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in
|
||||
/// time (a message trickled in can't outlast it). `None`: only the read timeout applies.
|
||||
pub fn read_until(&mut self, deadline: Option<Instant>) {
|
||||
self.deadline = deadline;
|
||||
}
|
||||
|
||||
/// What was read so far became a message (handed on): it no longer counts as being read.
|
||||
pub fn read_done(&mut self) {
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
@@ -187,6 +195,13 @@ impl Duplex {
|
||||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if let Some(d) = self.deadline {
|
||||
let left = d.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
self.read.set_read_timeout(Some(left))?;
|
||||
}
|
||||
let n = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it
|
||||
//! can't have a message of the full size read while its token is unchecked.
|
||||
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Server};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use super::{Admission, Backend, Closing, Hosting};
|
||||
use crate::net::NetLimits;
|
||||
|
||||
/// A backend that refuses every hello, once it is told to.
|
||||
struct Slow(Receiver<()>);
|
||||
|
||||
impl Backend for Slow {
|
||||
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
|
||||
let _ = self.0.recv();
|
||||
Err("wrong token".into())
|
||||
}
|
||||
|
||||
fn open(&mut self, _file: &str) -> Result<Server, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Ended
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(addr: SocketAddr) -> tungstenite::WebSocket<TcpStream> {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unadmitted_connection_cant_send_a_large_message() {
|
||||
let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() };
|
||||
let (release, wait) = channel();
|
||||
let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap();
|
||||
let addr = hosting.addr();
|
||||
let sender = std::thread::spawn(move || {
|
||||
let mut ws = connect(addr);
|
||||
let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap();
|
||||
ws.send(Message::Binary(Bytes::from(hello))).unwrap();
|
||||
let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) });
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap())));
|
||||
let mut refused = None;
|
||||
while let Ok(m) = ws.read() {
|
||||
if let Message::Binary(b) = m
|
||||
&& let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20)
|
||||
{
|
||||
refused = Some(reason);
|
||||
}
|
||||
}
|
||||
refused
|
||||
});
|
||||
let mut peak = 0;
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until {
|
||||
peak = peak.max(hosting.in_flight_bytes());
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
release.send(()).unwrap();
|
||||
let refused = sender.join().unwrap();
|
||||
hosting.stop();
|
||||
assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked");
|
||||
assert_eq!(refused.as_deref(), Some("wrong token"));
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl BlendMode {
|
||||
/// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come
|
||||
/// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`).
|
||||
pub fn from_name(s: &str) -> Option<Self> {
|
||||
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
let key: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key)
|
||||
|
||||
+27
-4
@@ -549,7 +549,7 @@ pub enum PaintKind {
|
||||
}
|
||||
|
||||
/// One fill or stroke paint.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Paint {
|
||||
#[serde(flatten)]
|
||||
pub kind: PaintKind,
|
||||
@@ -559,19 +559,41 @@ pub struct Paint {
|
||||
pub visible: bool,
|
||||
#[serde(default)]
|
||||
pub blend: BlendMode,
|
||||
/// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]),
|
||||
/// the rest being an opacity set on its own: a colour variable bound to the paint replaces
|
||||
/// this part only. 1: all of it is the paint's own.
|
||||
#[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")]
|
||||
pub color_alpha: f32,
|
||||
}
|
||||
|
||||
/// Paints compare by what they paint; where the opacity came from doesn't matter.
|
||||
impl PartialEq for Paint {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend
|
||||
}
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_one(x: &f32) -> bool {
|
||||
*x == 1.0
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn solid(color: Color) -> Self {
|
||||
Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal }
|
||||
Self {
|
||||
kind: PaintKind::Solid { color: color.with_alpha(1.0) },
|
||||
opacity: color.a,
|
||||
visible: true,
|
||||
blend: BlendMode::Normal,
|
||||
color_alpha: color.a,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gradient(g: Gradient) -> Self {
|
||||
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }
|
||||
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
|
||||
}
|
||||
|
||||
pub fn image(hash: impl Into<String>, mode: ImageScaleMode) -> Self {
|
||||
@@ -587,12 +609,13 @@ impl Paint {
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
blend: BlendMode::Normal,
|
||||
color_alpha: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer `source` (a node id) tiled at its own size.
|
||||
pub fn pattern(source: u64) -> Self {
|
||||
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal }
|
||||
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
|
||||
}
|
||||
|
||||
/// The solid colour (with the paint opacity folded into alpha), if solid.
|
||||
|
||||
@@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)),
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
|
||||
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
|
||||
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } => network.fill_contains(lp),
|
||||
_ => path.winding(lp) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-11
@@ -275,7 +275,16 @@ impl Document {
|
||||
/// A fresh unused id (in this document's client space during a collaboration session; see
|
||||
/// [`set_client_id`](Self::set_client_id)).
|
||||
pub fn alloc_id(&mut self) -> NodeId {
|
||||
self.next_id.alloc()
|
||||
// A file's saved `next_id` can be stale (behind ids already in use): skip taken ids.
|
||||
let mut id = self.next_id.alloc();
|
||||
while self.nodes.contains_key(&id) {
|
||||
let next = self.next_id.alloc();
|
||||
if next == id {
|
||||
break;
|
||||
}
|
||||
id = next;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// The id [`alloc_id`](Self::alloc_id) returns next.
|
||||
@@ -459,8 +468,9 @@ impl Document {
|
||||
return true;
|
||||
}
|
||||
guard += 1;
|
||||
if guard > 10_000 {
|
||||
return false;
|
||||
// More hops than nodes means the parent chain loops; fail closed so callers reject the move.
|
||||
if guard > self.nodes.len() {
|
||||
return true;
|
||||
}
|
||||
cur = self.node(c).and_then(|n| n.parent);
|
||||
}
|
||||
@@ -827,6 +837,11 @@ impl Document {
|
||||
return Err(DocError::NotContainer(parent));
|
||||
}
|
||||
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
|
||||
// Never overwrite an existing node, or another copy, with a copy.
|
||||
let mut copied = std::collections::BTreeSet::new();
|
||||
if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) {
|
||||
return Err(DocError::Invalid(format!("node {taken} already exists")));
|
||||
}
|
||||
for n in nodes {
|
||||
let Some(new_id) = map.get(&n.id) else { continue };
|
||||
let mut c = n.clone();
|
||||
@@ -994,39 +1009,46 @@ impl Document {
|
||||
|
||||
/// Check structural invariants (used by tests and after loading untrusted files).
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
// Hostile files can break an invariant per child; keep only the first few reports.
|
||||
const MAX_ISSUES: usize = 100;
|
||||
fn note(issues: &mut Vec<String>, msg: impl FnOnce() -> String) {
|
||||
if issues.len() < MAX_ISSUES {
|
||||
issues.push(msg());
|
||||
}
|
||||
}
|
||||
let mut issues = Vec::new();
|
||||
for (id, n) in self.nodes.iter() {
|
||||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
note(&mut issues, || format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for c in &n.children {
|
||||
if !seen.insert(*c) {
|
||||
issues.push(format!("child {c} listed twice in {id}"));
|
||||
note(&mut issues, || format!("child {c} listed twice in {id}"));
|
||||
}
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
None => issues.push(format!("child {c} of {id} missing")),
|
||||
Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")),
|
||||
None => note(&mut issues, || format!("child {c} of {id} missing")),
|
||||
}
|
||||
}
|
||||
if let Some(p) = n.parent
|
||||
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
|
||||
{
|
||||
issues.push(format!("{id} not listed in parent {p}"));
|
||||
note(&mut issues, || format!("{id} not listed in parent {p}"));
|
||||
}
|
||||
// The library store's root is a page that is never listed.
|
||||
let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
|
||||
if n.is_page() != listed {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
note(&mut issues, || format!("page list mismatch for {id}"));
|
||||
}
|
||||
if n.is_page() && n.parent.is_some() {
|
||||
issues.push(format!("page {id} has a parent"));
|
||||
note(&mut issues, || format!("page {id} has a parent"));
|
||||
}
|
||||
}
|
||||
let reachable = self.reachable();
|
||||
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
|
||||
issues.push(format!("{id} is not under a page (cycle or detached)"));
|
||||
note(&mut issues, || format!("{id} is not under a page (cycle or detached)"));
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ impl WidthPreset {
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<WidthPreset> {
|
||||
let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
|
||||
let s: String = s.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
WidthPreset::ALL
|
||||
.into_iter()
|
||||
.find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s))
|
||||
|
||||
@@ -169,7 +169,12 @@ impl Expr {
|
||||
})
|
||||
.collect();
|
||||
match (op, parts.as_slice()) {
|
||||
(ExprOp::Not, [x]) => format!("not {x}"),
|
||||
// `not done` reads as a variable named "not done" if there is one: then
|
||||
// `not (done)`.
|
||||
(ExprOp::Not, [x]) => {
|
||||
let bare = format!("not {x}");
|
||||
if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") }
|
||||
}
|
||||
(ExprOp::Negate, [x]) => format!("-{x}"),
|
||||
_ => parts.join(&format!(" {} ", op.symbol())),
|
||||
}
|
||||
|
||||
@@ -459,16 +459,19 @@ impl Parser<'_> {
|
||||
if self.at_keyword("mode") && self.next_after(4) == Some('(') {
|
||||
return self.mode_of();
|
||||
}
|
||||
if let Some((id, n)) = self.variable_here() {
|
||||
self.i += n;
|
||||
return self.in_mode(id);
|
||||
}
|
||||
let var = self.variable_here();
|
||||
// A bare `true`/`false` is the literal, also when a variable has that name (it reads
|
||||
// as `{true}`); longer names that start with it ("true value") stay variables.
|
||||
for (word, v) in [("true", true), ("false", false)] {
|
||||
if self.at_keyword(word) {
|
||||
if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) {
|
||||
self.i += word.len();
|
||||
return Ok(Expr::Value(VariableValue::Boolean(v)));
|
||||
}
|
||||
}
|
||||
if let Some((id, n)) = var {
|
||||
self.i += n;
|
||||
return self.in_mode(id);
|
||||
}
|
||||
let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect();
|
||||
if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) }
|
||||
}
|
||||
|
||||
@@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() {
|
||||
assert_eq!(parse("1"), n(1.0));
|
||||
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_of_a_variable_reads_back_when_a_name_starts_with_not() {
|
||||
use ExprOp::*;
|
||||
let mut v = vars();
|
||||
let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone();
|
||||
for (id, name) in [("done", "done"), ("nd", "not done")] {
|
||||
let mut x = b.clone();
|
||||
x.id = id.into();
|
||||
x.name = name.into();
|
||||
v.variables.push(x);
|
||||
}
|
||||
let not_done = Expr::op(Not, vec![Expr::var("done")]);
|
||||
for e in [
|
||||
not_done.clone(),
|
||||
Expr::op(And, vec![Expr::var("b"), not_done.clone()]),
|
||||
Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]),
|
||||
Expr::op(Not, vec![not_done.clone()]),
|
||||
] {
|
||||
let shown = e.display(&v);
|
||||
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}");
|
||||
}
|
||||
assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_true_false_survive_variables_named_true_false() {
|
||||
use ExprOp::*;
|
||||
let lit = |x: bool| Expr::Value(VariableValue::Boolean(x));
|
||||
for name in ["true", "false", "TRUE"] {
|
||||
let mut v = vars();
|
||||
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") {
|
||||
var.name = name.into();
|
||||
}
|
||||
for e in [
|
||||
Expr::op(Equals, vec![Expr::var("b"), lit(true)]),
|
||||
Expr::op(Equals, vec![Expr::var("b"), lit(false)]),
|
||||
Expr::op(Not, vec![lit(false)]),
|
||||
Expr::var("b"),
|
||||
] {
|
||||
let shown = e.display(&v);
|
||||
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
|
||||
}
|
||||
assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() {
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_caps_the_issue_list() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]);
|
||||
let bad: Document = serde_json::from_value(v).unwrap();
|
||||
let issues = bad.validate();
|
||||
assert!(!issues.is_empty());
|
||||
assert!(issues.len() <= 100, "{}", issues.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_loads_files_without_grid_tracks() {
|
||||
// Saved before grid tracks, cells and spans existed.
|
||||
@@ -464,3 +478,84 @@ fn text_style_empty_range_links_and_unlinks_all() {
|
||||
e.unlink_text_style(0, 0);
|
||||
assert!(e.style.style_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_next_id_never_overwrites_existing_nodes() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["next_id"] = serde_json::json!(1);
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
let before: Vec<(NodeId, String, Option<NodeId>)> =
|
||||
[p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect();
|
||||
let n = bad.node_count();
|
||||
let copy = bad.duplicate_into(f, p, None).unwrap();
|
||||
assert!(![p, f, a].contains(©));
|
||||
assert_eq!(bad.node_count(), n + 2);
|
||||
let copies = bad.duplicate_all_into(&[f], p).unwrap();
|
||||
assert_eq!(bad.node_count(), n + 4);
|
||||
assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c)));
|
||||
for (i, name, parent) in &before {
|
||||
let node = bad.node(*i).unwrap();
|
||||
assert_eq!((&node.name, node.parent), (name, *parent));
|
||||
}
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
|
||||
// A paste whose id map points at an existing node fails and changes nothing.
|
||||
let nodes: Vec<Node> = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
|
||||
map.insert(a, p);
|
||||
let snapshot = bad.clone();
|
||||
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
|
||||
assert_eq!(bad, snapshot);
|
||||
assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name);
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
|
||||
// So does one that maps two nodes to the same new id.
|
||||
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
|
||||
map.insert(a, map[&f]);
|
||||
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
|
||||
assert_eq!(bad, snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_even_odd_vector_hole_misses() {
|
||||
let ring = |rule| {
|
||||
let sq = |o: f64, s: f64, base: usize| {
|
||||
(
|
||||
[(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)),
|
||||
[Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)],
|
||||
)
|
||||
};
|
||||
let (v1, s1) = sq(0.0, 200.0, 0);
|
||||
let (v2, s2) = sq(60.0, 80.0, 4);
|
||||
VectorNetwork {
|
||||
vertices: v1.into_iter().chain(v2).collect(),
|
||||
segments: s1.into_iter().chain(s2).collect(),
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
|
||||
}
|
||||
};
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo));
|
||||
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled");
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_ancestor_holds_for_very_deep_chains() {
|
||||
let mut d = Document::new("x");
|
||||
let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let mut leaf = root;
|
||||
for _ in 0..10_005 {
|
||||
leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
}
|
||||
assert!(d.is_ancestor(root, leaf));
|
||||
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
|
||||
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
|
||||
assert_eq!(fills.len(), 2, "the second fill stays");
|
||||
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
|
||||
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
|
||||
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
|
||||
// Removing the bound (first) fill takes the binding with it.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
|
||||
|
||||
@@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() {
|
||||
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_keeps_an_opacity_set_on_the_paint() {
|
||||
// A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only.
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"}));
|
||||
let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]});
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap();
|
||||
let r = NodeId(r["id"].as_u64().unwrap());
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"}));
|
||||
let p = s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert!(near(p.opacity, 0.15), "{}", p.opacity);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15));
|
||||
// An opacity set on a `#RRGGBBAA` fill is the user's too, and stays.
|
||||
let (mut s, r) = setup("#FF0000", "#FF0000");
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
|
||||
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
|
||||
let (mut s, r) = setup("#FF000080", "#FF000080");
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
|
||||
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"component.resetOverride",
|
||||
"Reset override",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
@@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layers": pushed}))
|
||||
}
|
||||
|
||||
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.restoreMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
@@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The main comes back with its old id (other instances point to it) and its layers with the
|
||||
// ids their copies mirror, so every instance re-links by source.
|
||||
let sub = doc.subtree(i);
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
let map = restore_id_map(doc, i, component, &sub);
|
||||
let mut nodes = Vec::new();
|
||||
let mut nested = Vec::new();
|
||||
for d in &sub {
|
||||
let Some(mut n) = doc.node(*d).cloned() else { continue };
|
||||
if *d == i {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in &props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
let defs = restore_prop_defs(doc, &props);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs };
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
|
||||
@@ -535,6 +535,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -610,8 +611,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard =
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
|
||||
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
s.clipboard = crate::Clipboard {
|
||||
nodes,
|
||||
images,
|
||||
props,
|
||||
bounds,
|
||||
source_doc: 0,
|
||||
source: Some(doc),
|
||||
source_path: None,
|
||||
copied_components: BTreeSet::new(),
|
||||
layers_generation,
|
||||
};
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
@@ -1375,10 +1386,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
|
||||
if c.a < 1.0 {
|
||||
slot.opacity = c.a;
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
slot.visible = v;
|
||||
|
||||
@@ -488,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
@@ -1181,8 +1181,11 @@ fn export_path(name: &str) -> String {
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// `name` without path separators and characters file systems reject.
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
|
||||
/// control characters become `_`); everything else is kept, up to the first 200 characters.
|
||||
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
|
||||
/// the number and extension it adds).
|
||||
pub fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), 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, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
|
||||
fn paint_none(v: &Value) -> bool {
|
||||
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
|
||||
/// links after the values so a link wins over values its style sets, variable bindings last so
|
||||
/// paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
@@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, 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"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, true, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, false, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
|
||||
@@ -107,6 +107,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"vector.fillRule",
|
||||
"Set fill rule",
|
||||
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
|
||||
true,
|
||||
fill_rule
|
||||
),
|
||||
spec!(
|
||||
"vector.reverseLoop",
|
||||
"Reverse loop",
|
||||
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
|
||||
true,
|
||||
reverse_loop
|
||||
),
|
||||
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?} (in vector edit mode: joins the selected points)", true, |s, p| {
|
||||
@@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"ids": fitted}))
|
||||
}
|
||||
|
||||
/// The vector network of `id`, when it is a vector layer.
|
||||
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
|
||||
match &mut doc.get_mut(id).ok()?.kind {
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id a fill rule is written as.
|
||||
fn rule_id(rule: WindingRule) -> &'static str {
|
||||
match rule {
|
||||
WindingRule::NonZero => "nonZero",
|
||||
WindingRule::EvenOdd => "evenOdd",
|
||||
}
|
||||
}
|
||||
|
||||
/// `region` as an index: absent (or null) means every area.
|
||||
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
|
||||
match get(p, "region") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`region` out of range"));
|
||||
}
|
||||
Ok(Some(k as usize))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
|
||||
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
|
||||
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
|
||||
/// don't decide.
|
||||
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (r, region) in net.regions.iter().enumerate() {
|
||||
for (l, lp) in region.loops.iter().enumerate() {
|
||||
if (r, l) != skip && lp.len() <= 2 {
|
||||
out.extend(lp.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.fillRule", s, p)?;
|
||||
let rule = match string(p, "rule").as_deref().map(str::trim) {
|
||||
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
|
||||
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
|
||||
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
|
||||
};
|
||||
let only = area_index("vector.fillRule", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut count = 0usize;
|
||||
for id in ids {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
match only {
|
||||
Some(r) => {
|
||||
if let Some(region) = net.regions.get_mut(r) {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for region in &mut net.regions {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
|
||||
}
|
||||
Ok(json!({"regions": count, "rule": rule_id(rule)}))
|
||||
}
|
||||
|
||||
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
|
||||
/// for a loop of one or two segments, the one to store the other way.
|
||||
struct LoopFlip {
|
||||
id: NodeId,
|
||||
turned: Vec<usize>,
|
||||
store: Option<usize>,
|
||||
}
|
||||
|
||||
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
|
||||
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
|
||||
///
|
||||
/// How a loop is walked depends on how many segments it has:
|
||||
/// - three or more: the order they are listed in, each segment turned to continue from the point
|
||||
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
|
||||
/// segment's ends belong to its neighbour too);
|
||||
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
|
||||
/// - one: only its stored ends say which way it runs, so they are swapped.
|
||||
///
|
||||
/// A short loop can only turn round by re-storing a segment, which is refused when another short
|
||||
/// loop lists that segment too: it would turn that area round as well.
|
||||
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "vector.reverseLoop";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let region = req_index(cmd, p, "region")?;
|
||||
let index = req_index(cmd, p, "loop")?;
|
||||
let doc = s.doc_mut()?;
|
||||
// Work every loop out before touching the document: one that can't turn round refuses the call
|
||||
// and the others stay as they were.
|
||||
let mut flips: Vec<LoopFlip> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
|
||||
if loop_.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut store = None;
|
||||
if loop_.len() <= 2 {
|
||||
let busy = short_loop_segments(network, (region, index));
|
||||
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
|
||||
));
|
||||
};
|
||||
store = Some(pick);
|
||||
}
|
||||
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
|
||||
}
|
||||
if flips.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
|
||||
}
|
||||
for LoopFlip { id, turned, store } in flips {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
|
||||
std::mem::swap(&mut seg.a, &mut seg.b);
|
||||
std::mem::swap(&mut seg.ta, &mut seg.tb);
|
||||
}
|
||||
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
|
||||
*l = turned;
|
||||
}
|
||||
}
|
||||
Ok(json!({"reversed": true}))
|
||||
}
|
||||
|
||||
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
|
||||
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
|
||||
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
|
||||
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
@@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
if bind {
|
||||
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
|
||||
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
|
||||
p.color_alpha = 1.0;
|
||||
} else {
|
||||
p.opacity *= color.a;
|
||||
p.color_alpha *= color.a;
|
||||
}
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
||||
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
||||
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
|
||||
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
|
||||
spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
|
||||
let r = tool(s, ToolId::Eyedropper)?;
|
||||
s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
|
||||
s.tool.picked = None;
|
||||
Ok(r)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+127
-11
@@ -12,9 +12,10 @@
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
|
||||
//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
|
||||
//! `slot` (a slot holding the instance's own content). An instance's own export settings,
|
||||
//! interactions, resizing and variable modes are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
@@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"modes",
|
||||
"component",
|
||||
"props",
|
||||
SLOT,
|
||||
@@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
enclosing_instance(doc, id).is_some()
|
||||
}
|
||||
|
||||
/// Mark the `modes` override on layers inside instances whose variable modes differ from their
|
||||
/// main layer's, for files from before `modes` was an override group. Copies follow their main
|
||||
/// layer's modes, so a layer on other modes chose them itself.
|
||||
pub fn mark_layer_modes(doc: &mut Document) {
|
||||
let chose: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
|
||||
.map(|n| n.id)
|
||||
.filter(|id| inside_instance(doc, *id))
|
||||
.collect();
|
||||
for id in chose {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.overrides.insert("modes".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
|
||||
/// a slot property of its component, or an instance's copy of one (followed through its sources).
|
||||
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
|
||||
@@ -335,6 +354,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis)
|
||||
/// that is still `was`'s follows `new`; what the instance set itself stays in `own`.
|
||||
fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) {
|
||||
if own.sizing_h == was.sizing_h {
|
||||
own.sizing_h = new.sizing_h;
|
||||
}
|
||||
if own.sizing_v == was.sizing_v {
|
||||
own.sizing_v = new.sizing_v;
|
||||
}
|
||||
for (o, w, n) in [
|
||||
(&mut own.min_w, was.min_w, new.min_w),
|
||||
(&mut own.max_w, was.max_w, new.max_w),
|
||||
(&mut own.min_h, was.min_h, new.min_h),
|
||||
(&mut own.max_h, was.max_h, new.max_h),
|
||||
] {
|
||||
if *o == w {
|
||||
*o = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
|
||||
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
|
||||
@@ -415,6 +455,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"modes" => to.var_modes = from.var_modes.clone(),
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
*b = *a;
|
||||
@@ -779,6 +820,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
|
||||
let differs = diff_groups(prev, &comp);
|
||||
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
|
||||
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
|
||||
}
|
||||
carry_overrides(&root, &mut fresh, Carry::Root);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
@@ -830,9 +872,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
}
|
||||
// Nested instances showing another main than the main's copy (swapped): rebuild from it,
|
||||
// with the old layers below them this rebuild didn't reuse.
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect();
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
|
||||
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
|
||||
for n in nested {
|
||||
let below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
.iter()
|
||||
.filter(|(_, x)| {
|
||||
let mut cur = x.parent;
|
||||
@@ -847,10 +890,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
})
|
||||
.map(|(i, x)| (*i, x.clone()))
|
||||
.collect();
|
||||
take_placing_overrides(doc, n, &old, &mut below);
|
||||
sync_instance_depth(doc, n, depth + 1, below);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
|
||||
/// copies just made of the outer main's, and `below`) takes the overrides held along the source
|
||||
/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
|
||||
/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
|
||||
/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
|
||||
/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
|
||||
fn take_placing_overrides(
|
||||
doc: &mut Document,
|
||||
n: NodeId,
|
||||
before: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
) {
|
||||
let placing = placing_mains(doc, n);
|
||||
for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
|
||||
let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
|
||||
if let Some(layer) = doc.node_mut(id) {
|
||||
take(layer, &held);
|
||||
}
|
||||
}
|
||||
for layer in below.values_mut() {
|
||||
let held = held_along(doc, layer, &placing);
|
||||
if !held.is_empty() {
|
||||
take(std::sync::Arc::make_mut(layer), &held);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
|
||||
fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet<NodeId> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut cur = doc.node(n).and_then(|x| x.source);
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
|
||||
out.extend(owning_component(doc, s.id));
|
||||
cur = s.source;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
|
||||
/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
|
||||
fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut cur = layer.source;
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
|
||||
if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
|
||||
break;
|
||||
}
|
||||
for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
|
||||
out.entry(g.clone()).or_insert_with(|| s.clone());
|
||||
}
|
||||
cur = s.source;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Give `layer` the `held` groups it has no override of its own for.
|
||||
fn take(layer: &mut Node, held: &BTreeMap<String, std::sync::Arc<Node>>) {
|
||||
for (g, from) in held {
|
||||
if layer.overrides.insert(g.clone()) {
|
||||
copy_group(from, layer, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether main components `a` and `b` are two variants of one set.
|
||||
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
|
||||
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
|
||||
@@ -1049,6 +1159,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if a.interactions != b.interactions {
|
||||
out.push("interactions");
|
||||
}
|
||||
if a.var_modes != b.var_modes {
|
||||
out.push("modes");
|
||||
}
|
||||
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
|
||||
if x != y {
|
||||
out.push("component");
|
||||
@@ -1146,10 +1259,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
// An instance's own place, resizing, export settings, interactions and variable modes are
|
||||
// its own, not overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1969,7 +2082,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1993,8 +2106,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
// A top-level instance's resizing and modes are its own (a nested one's override its main
|
||||
// layer's).
|
||||
n.overrides.retain(|g| {
|
||||
!matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
|
||||
});
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide,
|
||||
/// "A" filling it). Returns (set, row, stacked).
|
||||
fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let mut variants = Vec::new();
|
||||
for (k, (name, props, child)) in [
|
||||
("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})),
|
||||
(
|
||||
"Layout=Stacked",
|
||||
json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}),
|
||||
json!({"sizingH": "fill"}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props})));
|
||||
ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child}));
|
||||
variants.push(c);
|
||||
}
|
||||
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||||
(set, variants[0], variants[1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() {
|
||||
let mut s = Session::new();
|
||||
let (set, row, stacked) = layout_set(&mut s);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||||
assert_eq!(component_of(&s, i), row);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}}));
|
||||
assert_eq!(component_of(&s, i), stacked);
|
||||
let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400})));
|
||||
let (n, d) = (node(&s, i), node(&s, direct));
|
||||
assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed");
|
||||
assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked");
|
||||
let a = layer(&s, i, "A").unwrap();
|
||||
assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance");
|
||||
// Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override.
|
||||
let col = idv(&ex(
|
||||
&mut s,
|
||||
"node.create",
|
||||
json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}),
|
||||
));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col}));
|
||||
ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}}));
|
||||
let a = layer(&s, i, "A").unwrap();
|
||||
assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills");
|
||||
assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's");
|
||||
assert_eq!(node(&s, a).width, node(&s, i).width);
|
||||
// Resizing the instance set itself stays across a switch.
|
||||
let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}}));
|
||||
assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays");
|
||||
assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_combinations_order_default_and_conflicts() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
|
||||
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() {
|
||||
let mut s = scene();
|
||||
let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]);
|
||||
s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone();
|
||||
both(&mut s);
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode");
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let detached = mirror(&mut s.doc, s.nested, page);
|
||||
rebase_instance(&mut s.doc, detached);
|
||||
let n = s.doc.node(detached).unwrap();
|
||||
assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
|
||||
let mut s = scene();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the
|
||||
//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn create_rect(s: &mut Session, fill: Value) -> NodeId {
|
||||
id(ex(
|
||||
s,
|
||||
"node.create",
|
||||
json!({
|
||||
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
|
||||
"props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1}
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() {
|
||||
for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, none.clone());
|
||||
let n = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert!(n.fills.is_empty(), "{none:?}: fills not empty");
|
||||
assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_none_clears_an_existing_fill_as_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, json!("#FF0000"));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_none_or_null_clears_the_strokes() {
|
||||
for none in [json!("none"), json!(null)] {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, json!("#FF0000"));
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_fill_none_gets_no_automatic_colour() {
|
||||
let mut s = Session::new();
|
||||
let t = id(ex(
|
||||
&mut s,
|
||||
"node.create",
|
||||
json!({
|
||||
"type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"}
|
||||
}),
|
||||
));
|
||||
assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_string_still_names_the_key() {
|
||||
let mut s = Session::new();
|
||||
let e = s
|
||||
.execute(
|
||||
"node.create",
|
||||
&json!({
|
||||
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
|
||||
"props": {"fill": "nope"}
|
||||
}),
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}");
|
||||
}
|
||||
@@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() {
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eyedropper_of_a_colour_picker_samples_without_filling() {
|
||||
// Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker,
|
||||
// which writes it to its own paint (a stroke, an effect...): the fill stays as it was.
|
||||
let mut s = Session::new();
|
||||
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
|
||||
rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000");
|
||||
let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
|
||||
assert_eq!(s.undo_depth().0, undo, "nothing to undo");
|
||||
assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into()));
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
|
||||
// The I key's eyedropper fills the selection again.
|
||||
s.execute("edit.pickColor", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
|
||||
assert_eq!(s.tool.picked, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_and_effects_through_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn run<T>(cmd: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".to_string());
|
||||
log::error!("command `{cmd}` panicked: {msg}");
|
||||
|
||||
@@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() {
|
||||
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
|
||||
}
|
||||
|
||||
/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button
|
||||
/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal).
|
||||
fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
tone(s);
|
||||
let icon = swatch(s, "Icon", None);
|
||||
let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
|
||||
let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame})));
|
||||
set_mode(s, button_icon, json!("A"));
|
||||
let button = id(run(s, "component.create", json!({"ids": [frame]})));
|
||||
let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
|
||||
let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer})));
|
||||
let nested = s.doc().unwrap().node(close).unwrap().children[0];
|
||||
set_mode(s, nested, json!("B"));
|
||||
run(s, "component.create", json!({"ids": [outer]}));
|
||||
(button, button_icon, nested)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() {
|
||||
let mut s = Session::new();
|
||||
let (button, button_icon, nested) = modal(&mut s);
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
// An edit the copies can't take as they are rebuilds the Button instance.
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy");
|
||||
// The main's layer choosing another mode doesn't reach it either.
|
||||
set_mode(&mut s, button_icon, json!("B"));
|
||||
set_mode(&mut s, button_icon, Value::Null);
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"}));
|
||||
assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)");
|
||||
assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (button, _, _) = modal(&mut s);
|
||||
// A Button placed on the page, its icon recoloured through the icon's mode.
|
||||
let placed = instance(&mut s, button);
|
||||
let icon = s.doc().unwrap().node(placed).unwrap().children[0];
|
||||
set_mode(&mut s, icon, json!("B"));
|
||||
assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override");
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"}));
|
||||
assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it");
|
||||
run(&mut s, "component.resetInstance", json!({"ids": [placed]}));
|
||||
assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() {
|
||||
let mut s = Session::new();
|
||||
let (button, _, nested) = modal(&mut s);
|
||||
// Saved before `modes` was an override group: the copy has its mode but no marker.
|
||||
s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
|
||||
let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id()));
|
||||
let p = path.to_string_lossy().into_owned();
|
||||
run(&mut s, "file.saveAs", json!({"path": p}));
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.open", json!({"path": p}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose");
|
||||
assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked");
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it");
|
||||
}
|
||||
|
||||
@@ -229,8 +229,10 @@ pub static KEYS: &[Binding] = &[
|
||||
k(View, "view.prevPage", &["PageUp"]),
|
||||
k(View, "view.nextPage", &["PageDown"]),
|
||||
// Zoom
|
||||
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]),
|
||||
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]),
|
||||
// Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=`
|
||||
// alone (the `+` key without Shift) zooms in (#973).
|
||||
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]),
|
||||
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]),
|
||||
k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]),
|
||||
k(Zoom, "view.zoomFit", &["Shift+1"]),
|
||||
k(Zoom, "view.zoomSelection", &["Shift+2"]),
|
||||
|
||||
@@ -168,6 +168,25 @@ fn display_per_platform() {
|
||||
assert_eq!(primary("polygon.none", mac), None);
|
||||
}
|
||||
|
||||
/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US
|
||||
/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck).
|
||||
#[test]
|
||||
fn zoom_keys_come_in_pairs() {
|
||||
let none = Mods::default();
|
||||
let shift = mods(true, false, false, false);
|
||||
let cmd = mods(false, false, true, false);
|
||||
let shift_cmd = mods(true, false, true, false);
|
||||
for p in PLATFORMS {
|
||||
for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] {
|
||||
assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}");
|
||||
assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}");
|
||||
}
|
||||
// The menus still show Cmd/Ctrl and `+`.
|
||||
assert_eq!(primary("view.zoomIn", p), Some("Cmd+="));
|
||||
assert_eq!(primary("view.zoomOut", p), Some("Cmd+-"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_keys_come_from_the_keymap() {
|
||||
assert_eq!(ToolId::Move.shortcut(), "V");
|
||||
|
||||
@@ -325,6 +325,17 @@ pub struct TextEdit {
|
||||
pub anchor: usize,
|
||||
/// The node was created by this edit (deleted if left empty).
|
||||
pub created: bool,
|
||||
/// Identity of this editing session (`textedit::begin` assigns a fresh one).
|
||||
///
|
||||
/// The IME owner is `(document uid, node)`, which stays the same through ordinary edits
|
||||
/// and caret moves; this token changes only when editing is started again, so an IME
|
||||
/// context is never silently inherited by a replaced target that reuses the same node.
|
||||
///
|
||||
/// It is deliberately not serialized: current consumers only inspect a live session. A
|
||||
/// serialized `TextEdit` restored from data would get zero here, so it must not be restored
|
||||
/// into a live editor without minting (or otherwise invalidating) a new session.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub editing_session: u64,
|
||||
/// An IME composition in progress, shown inline at the caret; the document gets it on commit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preedit: Option<Preedit>,
|
||||
@@ -414,6 +425,7 @@ impl DocState {
|
||||
}
|
||||
doc.link_layer_text_styles();
|
||||
cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
components::mark_layer_modes(&mut doc);
|
||||
// Initial import settlement belongs to the saved baseline, not the user's first edit.
|
||||
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);
|
||||
@@ -765,6 +777,10 @@ pub struct Clipboard {
|
||||
pub source_doc: u64,
|
||||
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
|
||||
pub copied_components: std::collections::BTreeSet<NodeId>,
|
||||
/// Monotonic generation of the copied layers (contents, images, bounds and source). A UI
|
||||
/// clipboard mirror records it, so a later programmatic copy that never touched the system
|
||||
/// clipboard is not mistaken for the user's own copy just because the text still matches.
|
||||
pub layers_generation: u64,
|
||||
/// The document copied from (pastes into other files follow its mains) and its file path.
|
||||
pub source: Option<Arc<Document>>,
|
||||
pub source_path: Option<String>,
|
||||
@@ -1690,6 +1706,8 @@ mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod fill_none_tests;
|
||||
#[cfg(test)]
|
||||
mod finite_geometry_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
@@ -1902,6 +1920,8 @@ mod variants_discovery_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_fill_rule_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_multi_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_offset_tests;
|
||||
|
||||
@@ -339,7 +339,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
let (bound, mode_colls) = used_variables(doc, &units);
|
||||
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound));
|
||||
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(ToString::to_string).chain(bound));
|
||||
snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect();
|
||||
let coll_ids: BTreeSet<&str> = snap
|
||||
.variables
|
||||
@@ -491,6 +491,11 @@ impl Cache {
|
||||
None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())),
|
||||
};
|
||||
}
|
||||
// The path comes from a document: a network share would be contacted (and on Windows get
|
||||
// the opener's credentials) by the stat below.
|
||||
if !craft_docpath::is_local(path) {
|
||||
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
let stamp = stat(path);
|
||||
// Checked before the cache: the limit is the caller's.
|
||||
if let Ok(st) = &stamp
|
||||
@@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
Err(NO_FILES.into())
|
||||
}
|
||||
|
||||
/// A library read from a file or a server, with the modes chosen inside its instances marked when
|
||||
/// it was published before they were overrides ([`crate::components::mark_layer_modes`]).
|
||||
pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary {
|
||||
crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc));
|
||||
lib
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(Some(lib)) => Ok(settled(lib)),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
Err(e) => Err(format!("{path}: {e}")),
|
||||
}
|
||||
|
||||
@@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() {
|
||||
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
|
||||
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_library_cache_refuses_a_path_off_this_computer() {
|
||||
let mut cache = crate::library::Cache::default();
|
||||
for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] {
|
||||
let e = cache.load(path).expect_err(&format!("{path} must be refused"));
|
||||
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
|
||||
/// The library in an archive from a server (untrusted: read with the library file's limits).
|
||||
pub fn read_archive(bytes: &[u8]) -> std::result::Result<PublishedLibrary, String> {
|
||||
match figcraft_format::read_library(Cursor::new(bytes)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(Some(lib)) => Ok(crate::library::settled(lib)),
|
||||
Ok(None) => Err("the server sent a file without a library".into()),
|
||||
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
|
||||
}
|
||||
|
||||
@@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main,
|
||||
/// the icon copy in Modal).
|
||||
fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) {
|
||||
theme(lib);
|
||||
let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
|
||||
ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
|
||||
let icon = idv(&ex(lib, "component.create", json!({"ids": [f]})));
|
||||
let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
|
||||
ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
|
||||
let button = idv(&ex(lib, "component.create", json!({"ids": [b]})));
|
||||
let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
|
||||
let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
|
||||
let nested = lib.doc().unwrap().node(close).unwrap().children[0];
|
||||
ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
|
||||
ex(lib, "component.create", json!({"ids": [m]}));
|
||||
(button, nested)
|
||||
}
|
||||
|
||||
/// The icon inside the Button inside library Modal instance `inst`.
|
||||
fn modal_icon(s: &Session, inst: NodeId) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_set_inside_nested_instances_come_along() {
|
||||
let d = dir("nested-modes");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let (button, _) = nested_mode_kit(&mut lib);
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
|
||||
let copy = |s: &Session| modal_icon(s, inst);
|
||||
assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along");
|
||||
// An update that rebuilds the Button copies keeps it too.
|
||||
ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, copy(&s)), "FFFFFF");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() {
|
||||
let d = dir("nested-modes-old");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let (_, nested) = nested_mode_kit(&mut lib);
|
||||
// Published before `modes` was an override group: the copy has its mode but no marker.
|
||||
lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
|
||||
assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() {
|
||||
assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty());
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property
|
||||
/// `Icon` on the nested icon: (icon, shape, button, nested).
|
||||
fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let (icon_main, shape, button, nested) = button(s);
|
||||
let main_shape = copy_of(s, nested, shape);
|
||||
ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}}));
|
||||
ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested}));
|
||||
(icon_main, shape, button, nested)
|
||||
}
|
||||
|
||||
/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`.
|
||||
fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) {
|
||||
ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}}));
|
||||
healthy(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_override_carries_onto_a_swapped_nested_instance() {
|
||||
// Documented: overrides survive a swap when layer names match, and a swapped icon takes the
|
||||
// colour the main component set (forum thread with staff replies). Rows: by the instance-swap
|
||||
// property and by instance.swap, onto a matching and an unmatched name. The main's size
|
||||
// override is geometry and stays out; the instance's own override wins over the main's.
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let main_shape = copy_of(&s, nested, shape);
|
||||
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}}));
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph");
|
||||
for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] {
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
if by_property {
|
||||
swap_icon(&mut s, i, to);
|
||||
} else {
|
||||
let inested = copy_of(&s, i, nested);
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to}));
|
||||
healthy(&s);
|
||||
}
|
||||
let glyph = kids(&s, copy_of(&s, i, nested))[0];
|
||||
assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}");
|
||||
assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out");
|
||||
}
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600})));
|
||||
let glyph = kids(&s, copy_of(&s, i, nested))[0];
|
||||
ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}}));
|
||||
swap_icon(&mut s, i, other);
|
||||
assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_override_carries_onto_a_swap_one_level_deeper() {
|
||||
// As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance.
|
||||
let mut s = Session::new();
|
||||
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"})));
|
||||
let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar})));
|
||||
let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]})));
|
||||
let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800})));
|
||||
let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested));
|
||||
ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_nested_instance_keeps_the_main_override_it_took() {
|
||||
// Documented (staff reply): later edits of the main's icon colour don't reach instances that
|
||||
// swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon
|
||||
// main's own fill.
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500})));
|
||||
swap_icon(&mut s, swapped, other);
|
||||
let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]);
|
||||
let main_shape = copy_of(&s, nested, shape);
|
||||
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}}));
|
||||
healthy(&s);
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
|
||||
s.undo().unwrap();
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into()));
|
||||
s.redo().unwrap();
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
|
||||
let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0];
|
||||
ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"}));
|
||||
healthy(&s);
|
||||
assert_eq!(glyph(&s, swapped), "111111");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() {
|
||||
// Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a
|
||||
// swap to it, its edits still reach the instance; a second swap doesn't carry them on.
|
||||
let mut s = Session::new();
|
||||
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let dot = icon(&mut s, 200.0, "Dot", "Shape");
|
||||
let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name})));
|
||||
let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f})));
|
||||
if let Some(c) = fill {
|
||||
let shape = kids(s, d)[0];
|
||||
ex(s, "node.set", json!({"id": shape, "props": {"fill": c}}));
|
||||
}
|
||||
(idv(&ex(s, "component.create", json!({"ids": [f]}))), d)
|
||||
};
|
||||
let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff"));
|
||||
let (other3, _) = with_dot(&mut s, 400.0, "Other3", None);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
swap_icon(&mut s, i, other2);
|
||||
let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0];
|
||||
assert_eq!(fill(&s, shape_in(&s)), "0000FF");
|
||||
let shape2 = kids(&s, dot2)[0];
|
||||
ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it");
|
||||
swap_icon(&mut s, i, other3);
|
||||
assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_out_icons_own_overrides_stay_with_it() {
|
||||
// Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the
|
||||
// Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built
|
||||
// on Base, shows Base's own fill.
|
||||
let mut s = Session::new();
|
||||
let base = icon(&mut s, 0.0, "Base", "Shape");
|
||||
let on_base = |s: &mut Session, x: f64, name: &str| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name})));
|
||||
let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f})));
|
||||
(idv(&ex(s, "component.create", json!({"ids": [f]}))), b)
|
||||
};
|
||||
let (a, a_base) = on_base(&mut s, 100.0, "A");
|
||||
let (b, _) = on_base(&mut s, 200.0, "B");
|
||||
let a_shape = kids(&s, a_base)[0];
|
||||
ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f})));
|
||||
let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800})));
|
||||
swap_icon(&mut s, i, b);
|
||||
let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0];
|
||||
assert_eq!(fill(&s, shape), "111111");
|
||||
assert!(node(&s, shape).overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() {
|
||||
// Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's
|
||||
// slot doesn't carry onto the swapped-in card, which shows its own default content.
|
||||
let mut s = Session::new();
|
||||
let card = |s: &mut Session, x: f64, name: &str| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name})));
|
||||
let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"})));
|
||||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}}));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
ex(s, "component.makeSlot", json!({"id": content}));
|
||||
c
|
||||
};
|
||||
let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B"));
|
||||
let chip = icon(&mut s, 400.0, "Chip", "Dot");
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f})));
|
||||
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}}));
|
||||
healthy(&s);
|
||||
let content = kids(&s, copy_of(&s, i, nested))[0];
|
||||
let names: Vec<String> = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect();
|
||||
assert_eq!(names, ["B default"]);
|
||||
assert!(!node(&s, content).overrides.contains(SLOT));
|
||||
}
|
||||
|
||||
@@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() {
|
||||
let ov = &s.tool.overlay;
|
||||
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() {
|
||||
// #970: with Shift the band shows where the point will land, at a 45° step from the last
|
||||
// anchor (the same length as the pointer is away), not under the pointer.
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
at(&mut s, PointerKind::Down, 100.0, 100.0);
|
||||
at(&mut s, PointerKind::Up, 100.0, 100.0);
|
||||
let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) };
|
||||
s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap();
|
||||
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
|
||||
assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor");
|
||||
assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}");
|
||||
s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap();
|
||||
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
|
||||
assert_eq!(y0, 100.0, "the point landed on the band");
|
||||
assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ const DENIED: &[&str] = &[
|
||||
"plugins.",
|
||||
"libraries.",
|
||||
"library.add",
|
||||
// Uploads the open document to a server.
|
||||
"library.publishToServer",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.repeat",
|
||||
@@ -132,6 +134,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if command == "image.place" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the image as `data`".into());
|
||||
}
|
||||
if command == "text.loadFont" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the font as `data`".into());
|
||||
}
|
||||
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
|
||||
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
|
||||
return Some("plugins can't write files: leave out `path` to get the result back".into());
|
||||
@@ -155,7 +160,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
/// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys.
|
||||
fn writes_widget_data(params: &Value, depth: usize) -> bool {
|
||||
let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX)));
|
||||
depth < 64
|
||||
// serde_json refuses nesting past 128, so nothing deeper can reach here.
|
||||
depth < 128
|
||||
&& match params {
|
||||
Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)),
|
||||
Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)),
|
||||
@@ -592,3 +598,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod refusal_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_only_plugin_cannot_publish_to_server() {
|
||||
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap();
|
||||
let r = refusal(&m, "library.publishToServer", &json!({}));
|
||||
assert!(r.is_some(), "library.publishToServer uploads the document and must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn widget_data_is_found_at_any_depth_serde_accepts() {
|
||||
let depth = 100;
|
||||
let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth));
|
||||
let v: Value = serde_json::from_str(&text).unwrap();
|
||||
assert!(writes_widget_data(&v, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
("file.saveAs", json!({"path": target})),
|
||||
("file.open", json!({"path": "C:/Windows/win.ini"})),
|
||||
("image.place", json!({"path": "secret.png"})),
|
||||
("text.loadFont", json!({"path": "/etc/hosts"})),
|
||||
("library.add", json!({"path": "lib.figcraft"})),
|
||||
("plugin.run", json!({"id": "org.test.deny"})),
|
||||
("plugin.remove", json!({"id": "org.test.deny"})),
|
||||
|
||||
@@ -15,8 +15,12 @@ struct SaveFiles {
|
||||
|
||||
impl SaveFiles {
|
||||
fn new() -> Self {
|
||||
// Tests run in parallel and the clock can read the same for two of them (macOS counts
|
||||
// microseconds), so a counter keeps their folders apart.
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self { dir, files: Vec::new() }
|
||||
}
|
||||
|
||||
@@ -324,6 +324,49 @@ fn shift_drag_moves_the_selection() {
|
||||
assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
for i in [k.r, k.c] {
|
||||
ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
}
|
||||
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
|
||||
// (selection before, drag from, selection after, the layers that move by (10, 10), derivation)
|
||||
let rows: Vec<(Vec<NodeId>, (f64, f64), Vec<NodeId>, Vec<NodeId>, &str)> = vec![
|
||||
(vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"),
|
||||
(vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"),
|
||||
(vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"),
|
||||
(vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"),
|
||||
];
|
||||
for (before, from, after, moved, why) in rows {
|
||||
let home: Vec<Point> = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect();
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) {
|
||||
let d = if moved.contains(i) { 10.0 } else { 0.0 };
|
||||
assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}");
|
||||
}
|
||||
if !moved.is_empty() {
|
||||
assert!(s.undo().unwrap(), "{why}: one undo step");
|
||||
}
|
||||
}
|
||||
// A turned layer's box is turned with it: the corners of its page-aligned bounds are outside.
|
||||
ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}}));
|
||||
let b = s.doc().unwrap().world_bounds(k.r).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r]}));
|
||||
drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE);
|
||||
assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer");
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r]}));
|
||||
let c = b.center();
|
||||
drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE);
|
||||
assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags");
|
||||
// Pixel snapping rounds the turned box's origin, so the move is about the pointer's.
|
||||
let d = at(&s, k.r) - b.origin();
|
||||
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -171,8 +171,8 @@ static ROWS: &[Row] = &[
|
||||
doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""),
|
||||
doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""),
|
||||
doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""),
|
||||
pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]),
|
||||
pin(Zoom, "view.zoomOut", &["Shift+-"], &[]),
|
||||
pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]),
|
||||
pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]),
|
||||
pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]),
|
||||
pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]),
|
||||
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
|
||||
|
||||
@@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key linking a style of `kind` (a paint style links fills).
|
||||
fn style_key(kind: &str) -> String {
|
||||
if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") }
|
||||
}
|
||||
|
||||
/// `node.create` params for an unselected layer that takes `kind` styles, with `props`.
|
||||
fn new_layer(kind: &str, props: Value) -> Value {
|
||||
if kind == "text" {
|
||||
json!({"type": "text", "text": "Hej", "select": false, "props": props})
|
||||
} else {
|
||||
json!({"type": "frame", "select": false, "props": props})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_holds_the_whole_property_and_links_its_source() {
|
||||
// Documented: colour styles can hold several fills (gradients, images); effect styles combine
|
||||
@@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() {
|
||||
let _ = st;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_link_wins_over_values_in_the_same_call() {
|
||||
// Docs silent on one call setting both (a linked layer shows its style's values; editing one
|
||||
// detaches it); pinned: the link wins whatever the keys are called, on node.create and
|
||||
// node.set, and a deviation set in a later call unlinks as before.
|
||||
let mut s = Session::new();
|
||||
let rows = styled(&mut s);
|
||||
let pairs: [(&str, &str, Value); 12] = [
|
||||
("paint", "fill", json!("#123456")),
|
||||
("paint", "fills", json!(["#123456"])),
|
||||
("stroke", "stroke", json!("#123456")),
|
||||
("stroke", "strokes", json!(["#123456"])),
|
||||
("effect", "effects", json!([])),
|
||||
("grid", "grids", json!([])),
|
||||
("grid", "layoutGrids", json!([])),
|
||||
("text", "fontStyle", json!("Bold")),
|
||||
("text", "lineHeight", json!(40)),
|
||||
("text", "textDecoration", json!("underline")),
|
||||
("text", "letterSpacing", json!(5)),
|
||||
("text", "variationSettings", json!({"wght": 700})),
|
||||
];
|
||||
for (kind, key, value) in pairs {
|
||||
let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone();
|
||||
let link_key = style_key(kind);
|
||||
let props = json!({key: value.clone(), link_key.as_str(): st.clone()});
|
||||
let made = id(run(&mut s, "node.create", new_layer(kind, props.clone())));
|
||||
let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) };
|
||||
run(&mut s, "node.set", json!({"id": set, "props": props}));
|
||||
for (how, layer) in [("node.create", made), ("node.set", set)] {
|
||||
assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked");
|
||||
assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values");
|
||||
}
|
||||
run(&mut s, "node.set", json!({"id": set, "props": {key: value}}));
|
||||
assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks");
|
||||
assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_link_leaves_values_its_style_does_not_set() {
|
||||
// Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set.
|
||||
let mut s = Session::new();
|
||||
let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1;
|
||||
let props = json!({"textStyle": st, "textAlign": "center"});
|
||||
let made = id(run(&mut s, "node.create", new_layer("text", props.clone())));
|
||||
let set = text(&mut s, 400.0);
|
||||
run(&mut s, "node.set", json!({"id": set, "props": props}));
|
||||
for (how, layer) in [("node.create", made), ("node.set", set)] {
|
||||
assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked");
|
||||
assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_style_replaces_variables_bound_to_that_property() {
|
||||
// Documented: applying a style sets the property to the style's values. Docs silent on bound
|
||||
|
||||
@@ -28,6 +28,53 @@ fn fill(doc: &mut Document, id: NodeId, c: Color) {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_screen_header(doc: &mut Document, screen: NodeId, s: usize) -> DocResult<()> {
|
||||
let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?;
|
||||
if let Some(n) = doc.node_mut(header) {
|
||||
n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() });
|
||||
n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))];
|
||||
}
|
||||
doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?;
|
||||
for k in 0..2 {
|
||||
let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?;
|
||||
fill(doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_card_group(doc: &mut Document, screen: NodeId) -> DocResult<()> {
|
||||
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
|
||||
// Children are in the group's space; normalising fits the group around them.
|
||||
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
|
||||
fill(doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF));
|
||||
doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?;
|
||||
doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?;
|
||||
doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_screen_grid(doc: &mut Document, screen: NodeId, s: usize, rest: usize, made: &mut usize) -> DocResult<()> {
|
||||
for k in 0..rest {
|
||||
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
|
||||
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
|
||||
// The grid starts with a rectangle so even the smallest workload has one.
|
||||
let kind = (k + 2) % 5;
|
||||
let id = match kind {
|
||||
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
|
||||
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
|
||||
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
|
||||
};
|
||||
if kind != 0 {
|
||||
fill(doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.corners.radii = [3.0; 4];
|
||||
}
|
||||
}
|
||||
*made += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A page of about `layers` layers (pages not counted), normalised like an opened file. Returns
|
||||
/// the document and its page.
|
||||
pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
@@ -53,28 +100,13 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
let budget = |made: usize| layers.saturating_sub(made);
|
||||
// Auto layout header: title + two icons (4 layers).
|
||||
if budget(made) >= 4 {
|
||||
let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?;
|
||||
if let Some(n) = doc.node_mut(header) {
|
||||
n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() });
|
||||
n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))];
|
||||
}
|
||||
doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?;
|
||||
for k in 0..2 {
|
||||
let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?;
|
||||
fill(&mut doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99));
|
||||
}
|
||||
add_screen_header(&mut doc, screen, s)?;
|
||||
made += 4;
|
||||
}
|
||||
// A card group (5 layers).
|
||||
// The card and the instances leave one layer for the grid, which starts with a rectangle.
|
||||
if budget(made) > 5 {
|
||||
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
|
||||
// Children are in the group's space; normalising fits the group around them.
|
||||
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
|
||||
fill(&mut doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF));
|
||||
doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?;
|
||||
doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?;
|
||||
doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?;
|
||||
add_card_group(&mut doc, screen)?;
|
||||
made += 5;
|
||||
}
|
||||
// Three button instances (3 layers each).
|
||||
@@ -87,24 +119,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
}
|
||||
// Fill the rest of the screen with a grid of shapes and text.
|
||||
let rest = PER_SCREEN.saturating_sub(4 + 5 + 9 + 1).min(budget(made));
|
||||
for k in 0..rest {
|
||||
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
|
||||
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
|
||||
// The grid starts with a rectangle so even the smallest workload has one.
|
||||
let kind = (k + 2) % 5;
|
||||
let id = match kind {
|
||||
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
|
||||
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
|
||||
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
|
||||
};
|
||||
if kind != 0 {
|
||||
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.corners.radii = [3.0; 4];
|
||||
}
|
||||
}
|
||||
made += 1;
|
||||
}
|
||||
add_screen_grid(&mut doc, screen, s, rest, &mut made)?;
|
||||
if budget(made) == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ impl Kit {
|
||||
pub fn variables(&mut self, collection: &str, modes: &[&str], vars: Vec<(&str, &str, Vec<Value>)>) -> Result<()> {
|
||||
self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?;
|
||||
for (name, kind, values) in vars {
|
||||
let values: Map<String, Value> = modes.iter().map(|m| m.to_string()).zip(values).collect();
|
||||
let values: Map<String, Value> = modes.iter().map(ToString::to_string).zip(values).collect();
|
||||
self.run("variable.create", json!({"collection": collection, "name": name, "type": kind, "values": values}))?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -134,6 +134,58 @@ fn ime_composition_with_cjk() {
|
||||
assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() {
|
||||
// 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired
|
||||
// commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or
|
||||
// extended to grapheme clusters, and the whole edit remains one undo step.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24}));
|
||||
begin(&mut s, t, Some(4), false).unwrap();
|
||||
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after");
|
||||
assert_eq!(chars(&s, t), "ab\u{1F600}d");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
crate::textedit::set_caret(&mut s, 3, false).unwrap();
|
||||
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes");
|
||||
assert_eq!(chars(&s, t), "abd");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
// Past the start of the text: nothing more to delete, not an error.
|
||||
assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap());
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() {
|
||||
// The fork deletes the byte range exactly and leaves a character the range only partly
|
||||
// covers. FigCraft's editor counts characters with the same rule (mirroring egui's
|
||||
// `surrounding_bytes_to_chars`), including around a selection.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24}));
|
||||
let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap();
|
||||
begin(&mut s, t, Some(1), false).unwrap();
|
||||
assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
|
||||
crate::textedit::set_caret(&mut s, 2, false).unwrap();
|
||||
assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
|
||||
assert!(del(&mut s, 0, 4), "its four bytes cover it exactly");
|
||||
assert_eq!(chars(&s, t), "\u{e9}x");
|
||||
assert_eq!(caret(&s), (2, 2));
|
||||
// Complete bytes before the selection go; a character the range only partly covers stays,
|
||||
// and the selection itself is kept for the paired commit.
|
||||
crate::textedit::set_caret(&mut s, 1, false).unwrap();
|
||||
crate::textedit::set_caret(&mut s, 1, true).unwrap();
|
||||
crate::textedit::set_caret(&mut s, 2, true).unwrap();
|
||||
assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji");
|
||||
// The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left
|
||||
// with the deleted prefix and the selection direction is preserved.
|
||||
assert_eq!(chars(&s, t), "x");
|
||||
assert_eq!(caret(&s), (0, 1));
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_trim_through_node_set() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,9 +1,83 @@
|
||||
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData};
|
||||
|
||||
use crate::{EngineError, Preedit, Result, Session, TextEdit};
|
||||
|
||||
/// Most committed UTF-8 bytes an outgoing IME snapshot may describe.
|
||||
///
|
||||
/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its
|
||||
/// own constant so this layer has no egui dependency. The query below returns only this bounded
|
||||
/// window around the selection, never a copy of the whole document.
|
||||
pub const MAX_IME_SURROUNDING_BYTES: usize = 4000;
|
||||
|
||||
/// Stable identity of the on-canvas text edit an IME context belongs to.
|
||||
///
|
||||
/// `document` and `node` do not change through ordinary text or caret edits. `editing_session`
|
||||
/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and
|
||||
/// re-entering the same layer), so a replaced target never inherits an old composition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ImeEditOwner {
|
||||
/// The document tab's uid.
|
||||
pub document: u64,
|
||||
/// The edited text node.
|
||||
pub node: NodeId,
|
||||
/// [`TextEdit::editing_session`] of the current edit.
|
||||
pub editing_session: u64,
|
||||
}
|
||||
|
||||
/// Why the committed text around the IME caret cannot be described.
|
||||
///
|
||||
/// These are the only reasons this custom editor reports
|
||||
/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty
|
||||
/// text and a collapsed selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ImeSurroundingUnavailable {
|
||||
/// No text layer is being edited.
|
||||
NotEditing,
|
||||
/// The editing target is not a text node any more.
|
||||
NotText,
|
||||
/// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot
|
||||
/// can contain both selection ends.
|
||||
SelectionTooWide,
|
||||
/// Defensive: the computed window was not a character-boundary slice. The rounding above
|
||||
/// makes this unreachable for a well-formed text model.
|
||||
InvalidWindow,
|
||||
}
|
||||
|
||||
/// One bounded, committed logical-text observation for the outgoing IME snapshot.
|
||||
///
|
||||
/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this
|
||||
/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a
|
||||
/// valid reversed selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
/// Identity of the edit this observation belongs to.
|
||||
pub owner: ImeEditOwner,
|
||||
/// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection.
|
||||
pub text: String,
|
||||
/// UTF-8 byte offset of the primary caret in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// UTF-8 byte offset of the selection anchor in [`Self::text`].
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// Every editing session gets its own token; zero is valid and never reused deliberately.
|
||||
///
|
||||
/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics.
|
||||
static EDITING_SESSION: LazyLock<Mutex<u64>> = LazyLock::new(|| Mutex::new(0));
|
||||
|
||||
fn next_editing_session() -> u64 {
|
||||
let mut session = match EDITING_SESSION.lock() {
|
||||
Ok(session) => session,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*session = session.wrapping_add(1);
|
||||
*session
|
||||
}
|
||||
|
||||
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
|
||||
match &s.doc()?.get(id)?.kind {
|
||||
NodeKind::Text(t) => Ok(t.clone()),
|
||||
@@ -21,7 +95,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
|
||||
Some(c) => (c.min(n), c.min(n)),
|
||||
None => (n, 0),
|
||||
};
|
||||
ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None });
|
||||
ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None });
|
||||
ds.selection = vec![id];
|
||||
ds.revision += 1;
|
||||
s.live_text_notice(id);
|
||||
@@ -64,13 +138,29 @@ fn range(te: &TextEdit) -> (usize, usize) {
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
|
||||
edit_if(s, |t, te| {
|
||||
f(t, te);
|
||||
true
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// [`edit`] for a mutation that can still turn out to be a no-op.
|
||||
///
|
||||
/// The closure returns whether it changed the model; return `false` only before mutating (the
|
||||
/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the
|
||||
/// document untouched, so an IME deletion that finds nothing to delete does not cancel an
|
||||
/// in-progress composition.
|
||||
fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result<bool> {
|
||||
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);
|
||||
if !f(t, &mut te) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Any edit ends a composition (a commit replaces it with the final text).
|
||||
te.preedit = None;
|
||||
let len = t.char_count();
|
||||
@@ -82,16 +172,78 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||
crate::normalize(doc, &before);
|
||||
ds.text_edit = Some(te);
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// What an accepted IME `Commit` did to the committed model.
|
||||
///
|
||||
/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat
|
||||
/// (typing "- " at a line start turns the paragraph into a list) is an application
|
||||
/// transformation in the same command, so the frame's change must be reported as `Other`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ImeInsertion {
|
||||
/// Nothing was inserted (an empty, control-only, or bare-newline commit).
|
||||
Nothing,
|
||||
/// The committed text replaced the selection exactly.
|
||||
Plain,
|
||||
/// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending
|
||||
/// normalisation/control-character removal. The frame's change is application-origin.
|
||||
Transformed,
|
||||
}
|
||||
|
||||
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
|
||||
/// characters dropped, so pasted plain text from any platform lands cleanly.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if text.is_empty() {
|
||||
insert_model(s, text, false).map(|_| ())
|
||||
}
|
||||
|
||||
/// End the composition without changing the document, for an empty IME commit.
|
||||
///
|
||||
/// Returns without touching the session when no composition is active (or nothing is being
|
||||
/// edited): an empty commit is otherwise a no-op, never an error.
|
||||
fn clear_composition(s: &mut Session) -> Result<()> {
|
||||
let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some());
|
||||
if !composing {
|
||||
return Ok(());
|
||||
}
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(te) = ds.text_edit.as_mut() {
|
||||
te.preedit = None;
|
||||
}
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply an IME `Commit`, reporting whether it was a plain insertion.
|
||||
///
|
||||
/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain
|
||||
/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and
|
||||
/// control-character removal are application transformations and are reported as
|
||||
/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored.
|
||||
pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result<ImeInsertion> {
|
||||
insert_model(s, text, true)
|
||||
}
|
||||
|
||||
fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInsertion> {
|
||||
// Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a
|
||||
// real commit may do so, since typing Enter goes through `insert`.
|
||||
if ime_commit && matches!(text, "\n" | "\r") {
|
||||
return Ok(ImeInsertion::Nothing);
|
||||
}
|
||||
let normalized: String =
|
||||
crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if normalized.is_empty() {
|
||||
// An empty commit is how some integrations (Safari's egui web integration when the last
|
||||
// composition character is deleted) end the composition. It inserts nothing, but it must
|
||||
// still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm.
|
||||
if ime_commit {
|
||||
clear_composition(s)?;
|
||||
}
|
||||
return Ok(ImeInsertion::Nothing);
|
||||
}
|
||||
// A commit the editor had to transform is an application edit, not a plain insertion.
|
||||
let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed };
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
@@ -108,13 +260,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
t.replace_range(start, a, "");
|
||||
t.set_para_lists(start, start, |pl| pl.list = list);
|
||||
(te.caret, te.anchor) = (start, start);
|
||||
outcome = ImeInsertion::Transformed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
t.replace_range(a, b, &text);
|
||||
te.caret = a + text.chars().count();
|
||||
te.anchor = te.caret;
|
||||
})
|
||||
})?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
|
||||
@@ -339,6 +493,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the text surrounding the caret/selection that an IME reports with
|
||||
/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection
|
||||
/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself
|
||||
/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the
|
||||
/// selection are removed (a character the range only partly covers stays). Returns whether
|
||||
/// anything was deleted.
|
||||
pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
let (a, b) = range(&te);
|
||||
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
|
||||
if before == 0 && after == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_if(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
|
||||
if before == 0 && after == 0 {
|
||||
return false;
|
||||
}
|
||||
// Delete after the selection first so the before range's indices stay valid.
|
||||
if after > 0 {
|
||||
t.replace_range(b, b + after, "");
|
||||
}
|
||||
if before > 0 {
|
||||
t.replace_range(a - before, a, "");
|
||||
}
|
||||
// Keep the selection: both ends move left by the deleted prefix, reversed or not.
|
||||
te.caret = te.caret.saturating_sub(before);
|
||||
te.anchor = te.anchor.saturating_sub(before);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The
|
||||
/// selection is kept, as in stock `TextEdit`; only text before and after it is removed.
|
||||
pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
let (a, b) = range(&te);
|
||||
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
|
||||
let before = before_chars.min(a);
|
||||
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
|
||||
if before == 0 && after == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_if(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
|
||||
let before = before_chars.min(a);
|
||||
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
|
||||
if before == 0 && after == 0 {
|
||||
return false;
|
||||
}
|
||||
if after > 0 {
|
||||
t.replace_range(b, b + after, "");
|
||||
}
|
||||
if before > 0 {
|
||||
t.replace_range(a - before, a, "");
|
||||
}
|
||||
te.caret = te.caret.saturating_sub(before);
|
||||
te.anchor = te.anchor.saturating_sub(before);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass.
|
||||
///
|
||||
/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on
|
||||
/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers
|
||||
/// that need the end still pay O(n). See [`ime_surrounding`].
|
||||
fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) {
|
||||
let (mut a_byte, mut b_byte) = (None, None);
|
||||
for (i, (byte, _)) in text.char_indices().enumerate() {
|
||||
if a_byte.is_none() && i == a {
|
||||
a_byte = Some(byte);
|
||||
}
|
||||
if b_byte.is_none() && i == b {
|
||||
b_byte = Some(byte);
|
||||
}
|
||||
if a_byte.is_some() && b_byte.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len()))
|
||||
}
|
||||
|
||||
/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)`
|
||||
/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`.
|
||||
fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
||||
let (a_byte, b_byte) = char_byte_offsets(text, a, b);
|
||||
let from = a_byte.saturating_sub(before_bytes);
|
||||
let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count();
|
||||
let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count();
|
||||
(before, after)
|
||||
}
|
||||
|
||||
/// The committed text around the IME caret for the outgoing surrounding-text snapshot.
|
||||
///
|
||||
/// This is the edited node's logical model text (never the bidi display order), with the
|
||||
/// pre-edit excluded because a composition is UI-only in this editor until it is committed.
|
||||
/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed
|
||||
/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window
|
||||
/// is centred on the selection and rounded inward to character boundaries. An empty text layer
|
||||
/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`]
|
||||
/// only when there is no text edit, the target is not a text node, or the selection alone is
|
||||
/// wider than the budget.
|
||||
///
|
||||
/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into
|
||||
/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near
|
||||
/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around
|
||||
/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The
|
||||
/// document is never cloned here.
|
||||
pub fn ime_surrounding(s: &Session) -> std::result::Result<ImeSurrounding, ImeSurroundingUnavailable> {
|
||||
let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?;
|
||||
let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?;
|
||||
let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?;
|
||||
let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) };
|
||||
let text = t.characters.as_str();
|
||||
// One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`).
|
||||
let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor);
|
||||
let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection_len = selection_end - selection_start;
|
||||
if selection_len > MAX_IME_SURROUNDING_BYTES {
|
||||
return Err(ImeSurroundingUnavailable::SelectionTooWide);
|
||||
}
|
||||
// Spend the budget on both sides, giving room unused on one side to the other.
|
||||
let budget = MAX_IME_SURROUNDING_BYTES - selection_len;
|
||||
let before_available = selection_start;
|
||||
let after_available = text.len() - selection_end;
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let mut window_start = selection_start - before;
|
||||
let mut window_end = selection_end + after;
|
||||
// Round inward so the window stays within the byte budget and on character boundaries.
|
||||
while window_start < window_end && !text.is_char_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while window_end > window_start && !text.is_char_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
let Some(window) = text.get(window_start..window_end) else {
|
||||
return Err(ImeSurroundingUnavailable::InvalidWindow);
|
||||
};
|
||||
Ok(ImeSurrounding {
|
||||
owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session },
|
||||
text: window.to_owned(),
|
||||
cursor: cursor.saturating_sub(window_start),
|
||||
anchor: anchor.saturating_sub(window_start),
|
||||
})
|
||||
}
|
||||
|
||||
/// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars;
|
||||
/// default its end). The document doesn't change until the composition is committed with
|
||||
/// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as
|
||||
@@ -596,10 +904,16 @@ mod tests {
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 11));
|
||||
let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session;
|
||||
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (6, 11));
|
||||
assert_eq!(
|
||||
s.ds().unwrap().text_edit.as_ref().unwrap().editing_session,
|
||||
epoch,
|
||||
"a double-click inside the edit is an ordinary edit: same IME replacement epoch"
|
||||
);
|
||||
// Drag-select from the start of "Hello" to the middle.
|
||||
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
@@ -811,4 +1125,184 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() {
|
||||
let (mut s, id) = setup("café😀e\u{301}x");
|
||||
begin(&mut s, id, Some(8), false).unwrap();
|
||||
set_caret(&mut s, 8, false).unwrap();
|
||||
set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text, "café😀e\u{301}x");
|
||||
assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes");
|
||||
assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed");
|
||||
assert_eq!(ime.owner.node, id);
|
||||
assert_eq!(ime.owner.document, s.ds().unwrap().uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
preedit(&mut s, "にほ", Some(1)).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text, "Hi", "the composition is not committed text");
|
||||
assert_eq!((ime.cursor, ime.anchor), (2, 2));
|
||||
end(&mut s).unwrap();
|
||||
|
||||
let (mut s, id) = setup("");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() {
|
||||
let long = "a".repeat(5000);
|
||||
let (mut s, id) = setup(&long);
|
||||
begin(&mut s, id, Some(5000), false).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES);
|
||||
assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES));
|
||||
|
||||
// Exactly the budget is still describable; one more byte is not.
|
||||
set_caret(&mut s, 1000, false).unwrap();
|
||||
set_caret(&mut s, 5000, true).unwrap();
|
||||
let ime = ime_surrounding(&s).unwrap();
|
||||
assert_eq!(ime.text.len(), 4000);
|
||||
assert_eq!((ime.cursor, ime.anchor), (4000, 0));
|
||||
set_caret(&mut s, 999, false).unwrap();
|
||||
set_caret(&mut s, 5000, true).unwrap();
|
||||
assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let first = ime_surrounding(&s).unwrap().owner;
|
||||
insert(&mut s, "x").unwrap();
|
||||
set_caret(&mut s, 2, false).unwrap();
|
||||
assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner");
|
||||
end(&mut s).unwrap();
|
||||
begin(&mut s, id, Some(0), false).unwrap();
|
||||
let replaced = ime_surrounding(&s).unwrap().owner;
|
||||
assert_eq!(replaced.node, id);
|
||||
assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() {
|
||||
let (mut s, id) = setup("ab你好cd");
|
||||
begin(&mut s, id, Some(4), false).unwrap();
|
||||
set_caret(&mut s, 4, false).unwrap();
|
||||
set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好
|
||||
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
|
||||
// "b" (whole character before the selection) and "c" (whole character after it) go; the
|
||||
// selected "你好" stays for the paired commit, and the reversed direction is preserved.
|
||||
assert_eq!(data(&s, id).characters, "a你好d");
|
||||
assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed
|
||||
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
|
||||
assert_eq!(data(&s, id).characters, "你好");
|
||||
assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed
|
||||
assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection");
|
||||
assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() {
|
||||
let (mut s, id) = setup("ab你好cd");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
set_caret(&mut s, 4, true).unwrap(); // "你好"
|
||||
assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap());
|
||||
assert_eq!(data(&s, id).characters, "a你好d");
|
||||
assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix");
|
||||
set_caret(&mut s, 0, false).unwrap();
|
||||
// A composition in progress survives a delete that finds nothing to remove.
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap());
|
||||
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
|
||||
assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision");
|
||||
assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap());
|
||||
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
|
||||
assert_eq!(s.ds().unwrap().revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_owner_identity_changes_with_the_document() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
let first = ime_surrounding(&s).unwrap().owner;
|
||||
end(&mut s).unwrap();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap();
|
||||
let other = crate::params::id_of(&r["id"]).unwrap();
|
||||
begin(&mut s, other, Some(5), false).unwrap();
|
||||
let second = ime_surrounding(&s).unwrap().owner;
|
||||
assert_ne!(second.document, first.document, "another document is another IME owner");
|
||||
assert_eq!(second.node, other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_commit_reports_list_autoformat_as_an_application_transformation() {
|
||||
let (mut s, id) = setup("-");
|
||||
begin(&mut s, id, Some(1), false).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed);
|
||||
assert_eq!(data(&s, id).characters, "");
|
||||
assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted);
|
||||
assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain);
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() {
|
||||
let (mut s, id) = setup("Hi");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
// CRLF is inserted as LF, but the editor changed the commit: application-origin.
|
||||
assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed);
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
// Control characters are dropped; the commit itself is a no-op.
|
||||
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
// Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one.
|
||||
// (It also leaves a live composition alone, unlike the non-ignored commit below.)
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into()));
|
||||
preedit(&mut s, "", None).unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb");
|
||||
insert(&mut s, "\n").unwrap();
|
||||
assert_eq!(data(&s, id).characters, "Hia\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
|
||||
let (mut s, id) = setup("Hello");
|
||||
begin(&mut s, id, Some(5), false).unwrap();
|
||||
preedit(&mut s, "にほ", None).unwrap();
|
||||
assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into()));
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision");
|
||||
assert!(preedit_state(&s).is_none(), "an empty commit ends the composition");
|
||||
assert!(preedit_doc(&s).is_none());
|
||||
assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing");
|
||||
|
||||
// A control-only commit normalises to empty and ends the composition too.
|
||||
preedit(&mut s, "x", None).unwrap();
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision");
|
||||
assert!(preedit_state(&s).is_none());
|
||||
assert_eq!(data(&s, id).characters, "Hello");
|
||||
|
||||
// With no composition (and not even while editing) an empty commit stays a harmless no-op.
|
||||
let revision = s.ds().unwrap().revision;
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
assert_eq!(s.ds().unwrap().revision, revision);
|
||||
end(&mut s).unwrap();
|
||||
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +459,13 @@ pub(crate) enum Gesture {
|
||||
},
|
||||
}
|
||||
|
||||
/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step.
|
||||
fn at_45_degrees(last: Point, p: Point) -> Point {
|
||||
let v = p - last;
|
||||
let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0;
|
||||
last + Vec2::from_angle(ang) * v.hypot()
|
||||
}
|
||||
|
||||
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
|
||||
/// and out handles and the one before's out handle, those that aren't zero.
|
||||
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
|
||||
@@ -512,6 +519,11 @@ pub struct ToolState {
|
||||
pub pencil_brush: Option<figcraft_doc::Brush>,
|
||||
/// The board tools' colours and choices.
|
||||
pub board: crate::board::BoardTool,
|
||||
/// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the
|
||||
/// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill.
|
||||
pub pick_only: bool,
|
||||
/// The colour the eyedropper sampled for a colour picker, until the picker takes it.
|
||||
pub picked: Option<figcraft_color::Color>,
|
||||
}
|
||||
|
||||
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
|
||||
@@ -973,6 +985,7 @@ impl Session {
|
||||
}
|
||||
self.tool.tool = t;
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.pick_only = false;
|
||||
// The tool's cursor shows before the pointer moves.
|
||||
self.tool.overlay =
|
||||
Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() };
|
||||
@@ -1152,6 +1165,8 @@ impl Session {
|
||||
guides = g;
|
||||
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
|
||||
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
// With Shift the band ends where a click puts the point (#970).
|
||||
let to = if ev.shift { at_45_degrees(*last, to) } else { to };
|
||||
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
|
||||
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
|
||||
}
|
||||
@@ -1260,11 +1275,21 @@ impl Session {
|
||||
n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom()
|
||||
})
|
||||
};
|
||||
let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| {
|
||||
let label = label_at(&doc, page, &self.tool.labels, ev.p());
|
||||
let target = label.or_else(|| {
|
||||
self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
|
||||
.filter(|leaf| ev.cmd || !board_background(*leaf))
|
||||
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
|
||||
});
|
||||
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
|
||||
// inside, or the frame or canvas behind it) drags the selection.
|
||||
let behind_sel = |t: Option<NodeId>| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
|
||||
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
|
||||
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
|
||||
Some(first)
|
||||
}
|
||||
_ => target,
|
||||
};
|
||||
match target {
|
||||
Some(target) => {
|
||||
// Clicking inside the current selection's box keeps it (drag them all).
|
||||
@@ -2000,13 +2025,15 @@ impl Session {
|
||||
&& doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
|
||||
{
|
||||
// Entering a text selects all of it; in the text being edited, the word.
|
||||
// Entering a text selects all of it; in the text being edited, the word. A
|
||||
// double-click inside the running edit is an ordinary edit: starting a new session
|
||||
// here would replace the IME owner's epoch and drop a composition on a plain gesture.
|
||||
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target);
|
||||
let idx = self.text_index_at(target, ev.p())?;
|
||||
textedit::begin(self, target, Some(idx), false)?;
|
||||
if editing {
|
||||
textedit::select_word(self, idx)?;
|
||||
} else {
|
||||
textedit::begin(self, target, Some(idx), false)?;
|
||||
textedit::key(self, "SelectAll", false, false, false)?;
|
||||
}
|
||||
return Ok(());
|
||||
@@ -2134,7 +2161,9 @@ impl Session {
|
||||
255,
|
||||
);
|
||||
let sel = ds.selection.clone();
|
||||
if !sel.is_empty() {
|
||||
if self.tool.pick_only {
|
||||
self.tool.picked = Some(c);
|
||||
} else if !sel.is_empty() {
|
||||
self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?;
|
||||
}
|
||||
self.recent_colors.retain(|x| *x != c);
|
||||
@@ -2196,9 +2225,7 @@ impl Session {
|
||||
if ev.shift
|
||||
&& let Some((last, _, _)) = pen.points.last()
|
||||
{
|
||||
let v = p - *last;
|
||||
let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0;
|
||||
p = *last + Vec2::from_angle(ang) * v.hypot();
|
||||
p = at_45_degrees(*last, p);
|
||||
}
|
||||
pen.points.push((p, Vec2::ZERO, Vec2::ZERO));
|
||||
self.tool.gesture = Gesture::PenDrag { anchor: p };
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and
|
||||
//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade.
|
||||
//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not
|
||||
//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the
|
||||
//! direction of a loop doesn't matter).
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::{BezPath, Point};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn net(s: &Session, id: NodeId) -> VectorNetwork {
|
||||
match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
other => panic!("not a vector: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner
|
||||
/// one the same way round (so non-zero fills the hole too until it is turned round).
|
||||
fn ring() -> VectorNetwork {
|
||||
let mut path = BezPath::new();
|
||||
for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] {
|
||||
path.move_to((x0, y0));
|
||||
path.line_to((x1, y0));
|
||||
path.line_to((x1, y1));
|
||||
path.line_to((x0, y1));
|
||||
path.close_path();
|
||||
}
|
||||
let mut net = VectorNetwork::from_path(&path);
|
||||
net.regions[0].winding = WindingRule::NonZero;
|
||||
net
|
||||
}
|
||||
|
||||
/// A selected vector layer holding `network`.
|
||||
fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId {
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = NodeId(id);
|
||||
ex(s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(s, "vector.edit.end", json!({}));
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
match &mut doc.get_mut(id).unwrap().kind {
|
||||
NodeKind::Vector { network: net } => *net = network,
|
||||
other => panic!("not a vector: {other:?}"),
|
||||
}
|
||||
}
|
||||
ex(s, "select.set", json!({"ids": [id]}));
|
||||
id
|
||||
}
|
||||
|
||||
/// A selected vector layer holding `ring()`.
|
||||
fn ringed(s: &mut Session) -> NodeId {
|
||||
vector_layer(s, ring())
|
||||
}
|
||||
|
||||
/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by
|
||||
/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must
|
||||
/// not flip, or a neighbour's fill or stroke would turn round with it.
|
||||
fn two_squares() -> VectorNetwork {
|
||||
// 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100)
|
||||
// s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top),
|
||||
// s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top);
|
||||
// the right area's left edge is s1 walked the other way, so the squares share one segment.
|
||||
// region 0: left square A→B→E→F→A = [s0, s1, s2, s3].
|
||||
// region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards).
|
||||
VectorNetwork {
|
||||
vertices: vec![
|
||||
Vertex::new(0.0, 0.0),
|
||||
Vertex::new(50.0, 0.0),
|
||||
Vertex::new(100.0, 0.0),
|
||||
Vertex::new(100.0, 100.0),
|
||||
Vertex::new(50.0, 100.0),
|
||||
Vertex::new(0.0, 100.0),
|
||||
],
|
||||
segments: vec![
|
||||
Segment::line(0, 1),
|
||||
Segment::line(1, 4),
|
||||
Segment::line(4, 5),
|
||||
Segment::line(5, 0),
|
||||
Segment::line(1, 2),
|
||||
Segment::line(2, 3),
|
||||
Segment::line(3, 4),
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected vector layer holding [`two_squares`].
|
||||
fn squared(s: &mut Session) -> NodeId {
|
||||
vector_layer(s, two_squares())
|
||||
}
|
||||
|
||||
/// Two areas of two segments each over the same line: one curve above it and one below. Both areas
|
||||
/// list the line's segment — the first area first (`[line, above]`), the second area last
|
||||
/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so
|
||||
/// these only turn round when a segment is stored the other way.
|
||||
fn two_lenses() -> VectorNetwork {
|
||||
// 0:A(0,0) 1:B(100,0)
|
||||
// s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it.
|
||||
VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
|
||||
segments: vec![
|
||||
Segment::line(0, 1),
|
||||
Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) },
|
||||
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected vector layer holding [`two_lenses`].
|
||||
fn lensed(s: &mut Session) -> NodeId {
|
||||
vector_layer(s, two_lenses())
|
||||
}
|
||||
|
||||
/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight
|
||||
/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them.
|
||||
fn one_shared_segment() -> VectorNetwork {
|
||||
VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
|
||||
}
|
||||
}
|
||||
|
||||
/// The point in the middle of the hole, and one on the square's left band.
|
||||
const HOLE: Point = Point::new(50.0, 50.0);
|
||||
const BAND: Point = Point::new(10.0, 50.0);
|
||||
|
||||
#[test]
|
||||
fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() {
|
||||
let mut s = Session::new();
|
||||
let id = ringed(&mut s);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's");
|
||||
assert!(n.region_contains(0, BAND), "the band fills");
|
||||
assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way");
|
||||
// Turning the inner loop round makes it a hole under non-zero.
|
||||
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1}));
|
||||
assert_eq!(r["reversed"], json!(true));
|
||||
let n = net(&s, id);
|
||||
assert!(n.region_contains(0, BAND), "the band still fills");
|
||||
assert!(!n.region_contains(0, HOLE), "the hole is empty");
|
||||
assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there");
|
||||
// Undo turns it back.
|
||||
s.undo().unwrap();
|
||||
assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_switches_every_area_or_only_the_one_asked_for() {
|
||||
let mut s = Session::new();
|
||||
let id = ringed(&mut s);
|
||||
// Even-odd leaves the hole empty whatever way its loop runs.
|
||||
let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
|
||||
assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd")));
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.regions[0].winding, WindingRule::EvenOdd);
|
||||
assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing");
|
||||
// And back.
|
||||
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"}));
|
||||
assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again");
|
||||
// One area of several, by index.
|
||||
let n = net(&s, id);
|
||||
let mut two = n.clone();
|
||||
two.regions.push(n.regions[0].clone());
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
match &mut doc.get_mut(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => *network = two,
|
||||
other => panic!("not a vector: {other:?}"),
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule");
|
||||
assert_eq!(n.regions[1].winding, WindingRule::EvenOdd);
|
||||
// Every area at once.
|
||||
ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"}));
|
||||
assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_parameters_are_refused_and_change_nothing() {
|
||||
let mut s = Session::new();
|
||||
let id = ringed(&mut s);
|
||||
let before = net(&s, id);
|
||||
for p in [
|
||||
json!({"id": id, "rule": "nope"}),
|
||||
json!({"id": id}),
|
||||
json!({"id": id, "rule": 7}),
|
||||
json!({"id": id, "rule": "evenOdd", "region": -1}),
|
||||
json!({"id": id, "rule": "evenOdd", "region": 1.5}),
|
||||
json!({"id": id, "rule": "evenOdd", "region": "x"}),
|
||||
json!({"id": id, "rule": "evenOdd", "region": 99}),
|
||||
] {
|
||||
assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
for p in [
|
||||
json!({"id": id}),
|
||||
json!({"id": id, "region": 0}),
|
||||
json!({"id": id, "region": 0, "loop": 99}),
|
||||
json!({"id": id, "region": 99, "loop": 0}),
|
||||
json!({"id": id, "region": -2, "loop": 0}),
|
||||
] {
|
||||
assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
assert_eq!(net(&s, id), before, "nothing changed");
|
||||
// A rectangle that was never a vector is refused too.
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err());
|
||||
assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() {
|
||||
let mut s = Session::new();
|
||||
let id = squared(&mut s);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.regions.len(), 2, "two areas, side by side");
|
||||
// Both areas fill under non-zero: a point in each.
|
||||
assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills");
|
||||
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills");
|
||||
// The vertical edge between them is one segment listed by both areas' loops.
|
||||
const SHARED: usize = 1;
|
||||
assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared");
|
||||
let before = n.segments[SHARED];
|
||||
let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap();
|
||||
assert_ne!(left_winding, 0, "the left area winds once");
|
||||
// Reverse the left area's only loop.
|
||||
let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}));
|
||||
assert_eq!(r["reversed"], json!(true));
|
||||
let n = net(&s, id);
|
||||
// The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's
|
||||
// segment (reversing the list already turns this loop's traversal of it the other way).
|
||||
assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was");
|
||||
// The neighbour's fill is unchanged: it still fills its interior.
|
||||
assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills");
|
||||
// The reversed loop really turned round: its winding at a left-area point flipped sign.
|
||||
assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed");
|
||||
// Undo restores the shared segment and the loop.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() {
|
||||
let mut s = Session::new();
|
||||
let id = lensed(&mut s);
|
||||
const SHARED: usize = 0;
|
||||
const ABOVE: Point = Point::new(50.0, -25.0);
|
||||
const BELOW: Point = Point::new(50.0, 25.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.regions.len(), 2, "two areas over the same line");
|
||||
assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first");
|
||||
assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last");
|
||||
assert!(n.region_contains(0, ABOVE), "the area above the line fills");
|
||||
assert!(n.region_contains(1, BELOW), "the area below it fills");
|
||||
let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap());
|
||||
assert_ne!(above, 0, "the area above winds once");
|
||||
assert_ne!(below, 0, "the area below winds once");
|
||||
let shared = n.segments[SHARED];
|
||||
// Turn the area that lists the shared segment first round: a two-segment ring reads the same
|
||||
// either way, so this only works by storing one of its segments the other way.
|
||||
assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true));
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends");
|
||||
assert!(n.region_contains(0, ABOVE), "the area still fills");
|
||||
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round");
|
||||
assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not");
|
||||
// And the area that lists it last.
|
||||
ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.segments[SHARED], shared, "still untouched");
|
||||
assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round");
|
||||
assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was");
|
||||
// Undo turns each back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() {
|
||||
let mut s = Session::new();
|
||||
let id = vector_layer(&mut s, one_shared_segment());
|
||||
let before = net(&s, id);
|
||||
let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("shares its segments"), "the error says why: {message}");
|
||||
assert_eq!(net(&s, id), before, "nothing changed");
|
||||
}
|
||||
@@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() {
|
||||
assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
|
||||
s.undo().unwrap();
|
||||
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
|
||||
// Dragging one selected point drags them all.
|
||||
let active = editing(&s).unwrap().id;
|
||||
let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) };
|
||||
drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE);
|
||||
assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all");
|
||||
// Dragging one selected point drags them all (one on the transform box's edge: its corners
|
||||
// scale, as with points of one layer).
|
||||
let atr = point_at(&s, a, (100.0, 0.0));
|
||||
ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
|
||||
drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
|
||||
assert!(
|
||||
near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
|
||||
"dragging one selected point moves them all"
|
||||
);
|
||||
s.undo().unwrap();
|
||||
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
|
||||
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
|
||||
// Corner radius, mirroring and deleting reach every layer's selected points.
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
|
||||
assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
|
||||
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
|
||||
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`,
|
||||
//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and
|
||||
//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and
|
||||
//! hovering, hatching and selecting filled regions in [`regions`].
|
||||
|
||||
pub mod ops;
|
||||
|
||||
@@ -262,7 +262,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Every edited layer's selected points (segment ends included) other than the active one's.
|
||||
fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
|
||||
pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
|
||||
ve.others
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
@@ -310,7 +310,7 @@ impl Session {
|
||||
let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) };
|
||||
match bh {
|
||||
BoxHit::Inside => {
|
||||
let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?;
|
||||
let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?;
|
||||
self.begin_interaction("Move points")?;
|
||||
self.vgesture(g);
|
||||
}
|
||||
@@ -320,7 +320,7 @@ impl Session {
|
||||
self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 });
|
||||
}
|
||||
BoxHit::Handle(handle) => {
|
||||
let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[]));
|
||||
let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others));
|
||||
self.begin_interaction("Scale points")?;
|
||||
self.vgesture(VGesture::BoxScale { b, handle, targets, pick });
|
||||
}
|
||||
@@ -428,11 +428,11 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part
|
||||
/// there). Inside the transform box around points selected in their own right, the box keeps
|
||||
/// its drag unless the region is already selected.
|
||||
/// there). Inside the transform box around points selected in their own right (in any edited
|
||||
/// layer), the box keeps its drag unless the region is already selected.
|
||||
fn region_under(doc: &Document, ve: &VectorEdit, h: Option<VHit>, ev: &PointerEvent) -> Option<usize> {
|
||||
let r = region_hit(doc, ve, ev.p(), ev.zoom)?;
|
||||
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty();
|
||||
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected();
|
||||
let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside);
|
||||
(!in_box || ve.regions.contains(&r)).then_some(r)
|
||||
}
|
||||
@@ -883,7 +883,7 @@ impl Session {
|
||||
let q = b.frame.inverse() * (ev.p() + c);
|
||||
let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt);
|
||||
let m = b.onto(x0, y0, x1, y1);
|
||||
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
|
||||
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
|
||||
}
|
||||
VGesture::BoxRotate { b, start_angle, .. } => {
|
||||
let c = b.center();
|
||||
@@ -892,7 +892,7 @@ impl Session {
|
||||
angle = (angle / 15.0).round() * 15.0;
|
||||
}
|
||||
let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle);
|
||||
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
|
||||
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
|
||||
self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees());
|
||||
self.vgesture(VGesture::BoxRotate { b, start_angle, angle });
|
||||
}
|
||||
@@ -1007,10 +1007,7 @@ impl Session {
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
if click_clears {
|
||||
set_state(self, |e| {
|
||||
e.vertices.clear();
|
||||
e.segments.clear();
|
||||
})?;
|
||||
self.clear_points()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,23 +1034,7 @@ impl Session {
|
||||
}
|
||||
})?,
|
||||
VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?,
|
||||
VGesture::BoxScale { pick, .. } => {
|
||||
let ds = self.ds()?;
|
||||
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
|
||||
match pick.filter(|_| clicked) {
|
||||
Some(v) => {
|
||||
self.cancel_interaction()?;
|
||||
set_state(self, |e| {
|
||||
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
|
||||
e.vertices.remove(i);
|
||||
} else {
|
||||
e.vertices.push(v);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
None => self.commit_interaction()?,
|
||||
}
|
||||
}
|
||||
VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?,
|
||||
VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?,
|
||||
VGesture::Width(g) => self.width_up(g)?,
|
||||
}
|
||||
@@ -1063,6 +1044,34 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears the point selection of the active layer and of the other layers being edited.
|
||||
fn clear_points(&mut self) -> Result<()> {
|
||||
set_state(self, |e| {
|
||||
e.vertices.clear();
|
||||
e.segments.clear();
|
||||
for o in &mut e.others {
|
||||
o.vertices.clear();
|
||||
o.segments.clear();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's
|
||||
/// point `pick` toggles it in the selection; anything else keeps the scale.
|
||||
fn box_scale_up(&mut self, pick: Option<usize>) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
|
||||
let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() };
|
||||
self.cancel_interaction()?;
|
||||
set_state(self, |e| {
|
||||
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
|
||||
e.vertices.remove(i);
|
||||
} else {
|
||||
e.vertices.push(v);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its
|
||||
/// points, merges into that point (in the same undo step as the move), which stays selected.
|
||||
fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the
|
||||
//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt
|
||||
//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift:
|
||||
//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in
|
||||
//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
|
||||
//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners
|
||||
//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just
|
||||
//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves
|
||||
//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
|
||||
//! and while points are selected the flip and 90°/180° rotation commands act on them.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec};
|
||||
const EPS: f64 = 1e-9;
|
||||
|
||||
/// The selected points' box: the page transform of its top-left corner (axes along the vector's
|
||||
/// own), its size in those axes and the points in it.
|
||||
/// own), its size in those axes and the points in it: the active layer's, and other edited
|
||||
/// layers' in `others`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PointBox {
|
||||
pub frame: Affine,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub points: Vec<usize>,
|
||||
pub others: Vec<(NodeId, Vec<usize>)>,
|
||||
}
|
||||
|
||||
impl PointBox {
|
||||
/// How many points the box wraps, in every layer.
|
||||
pub fn point_count(&self) -> usize {
|
||||
self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Corners in page coordinates (tl, tr, br, bl).
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
let (f, w, h) = (self.frame, self.width, self.height);
|
||||
@@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
|
||||
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points })
|
||||
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] })
|
||||
}
|
||||
|
||||
/// The box of the selected points of every edited layer, in the page's axes (the layers' own may
|
||||
/// differ).
|
||||
fn layers_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
|
||||
let points = selected_points(network(doc, ve.id)?, ve);
|
||||
let others = Session::others_moving(doc, ve);
|
||||
let page = |id: NodeId, verts: &[usize]| -> Vec<Point> {
|
||||
let w = doc.world_transform(id);
|
||||
network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default()
|
||||
};
|
||||
let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v)));
|
||||
let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| {
|
||||
(a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y))
|
||||
});
|
||||
let (width, height) = (x1 - x0, y1 - y0);
|
||||
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others })
|
||||
}
|
||||
|
||||
/// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more
|
||||
/// points that aren't all in one place, all in the active layer.
|
||||
/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for.
|
||||
/// points that aren't all in one place, in any of the edited layers.
|
||||
pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
|
||||
if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() {
|
||||
if ve.tool != VTool::Move || ve.anchor.is_some() {
|
||||
return None;
|
||||
}
|
||||
point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS))
|
||||
let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) };
|
||||
b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS))
|
||||
}
|
||||
|
||||
/// What the transform box does under the pointer.
|
||||
@@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom:
|
||||
|
||||
/// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then
|
||||
/// re-fit the vector.
|
||||
pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> {
|
||||
pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> {
|
||||
let w = doc.world_transform(id);
|
||||
let local = w.inverse() * m * w;
|
||||
edit(doc, id, |net| ops::transform_vertices(net, verts, local))
|
||||
}
|
||||
|
||||
/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`.
|
||||
pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> {
|
||||
transform_points(doc, id, &b.points, m)?;
|
||||
for (o, verts) in &b.others {
|
||||
transform_points(doc, *o, verts, m)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transform the selected points by `f(box)` (page space) when editing with points selected and
|
||||
/// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on
|
||||
/// layers).
|
||||
|
||||
@@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one
|
||||
/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points
|
||||
/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags
|
||||
/// land exactly.
|
||||
fn two_layers(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
let (a, b) = (square(0.0), square(200.0));
|
||||
ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}}));
|
||||
ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}}));
|
||||
ex(s, "view.snapGeometry", json!({"on": false}));
|
||||
ex(s, "view.snapObjects", json!({"on": false}));
|
||||
ex(s, "view.snapPixel", json!({"on": false}));
|
||||
ex(s, "vector.edit.begin", json!({"ids": [a, b]}));
|
||||
let n = net(s, a);
|
||||
ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]}));
|
||||
ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true}));
|
||||
assert_eq!(ve(s).id, b, "the layer named last is the active one");
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Every selected point's page position, layer by layer.
|
||||
fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> {
|
||||
let e = ve(s);
|
||||
let parts = e.parts();
|
||||
[a, b]
|
||||
.iter()
|
||||
.flat_map(|id| {
|
||||
let part = parts.iter().find(|p| p.id == *id).unwrap();
|
||||
part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_wraps_points_selected_in_several_layers() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = two_layers(&mut s);
|
||||
let before = selected_page_points(&s, a, b);
|
||||
assert_eq!(before.len(), 6);
|
||||
// On the page's axes: `b` is turned, so neither layer's own axes fit both.
|
||||
let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y)));
|
||||
let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y)));
|
||||
let c = s.vector_edit_box().expect("a box around the points of both layers");
|
||||
assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})");
|
||||
|
||||
// The bottom-right corner dragged to twice the width scales every point in both layers.
|
||||
drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1));
|
||||
for (id, v, p) in &before {
|
||||
let q = page_pt(&s, *id, *v);
|
||||
assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}");
|
||||
}
|
||||
s.undo().unwrap();
|
||||
for (id, v, p) in &before {
|
||||
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
|
||||
}
|
||||
|
||||
// From just outside the top-right corner, a quarter turn clockwise about the box's centre.
|
||||
let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
let from = pt(x1 + 10.0, y0 - 10.0);
|
||||
let d = from - ctr;
|
||||
drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x));
|
||||
for (id, v, p) in &before {
|
||||
let r = *p - ctr;
|
||||
let q = page_pt(&s, *id, *v);
|
||||
assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}");
|
||||
}
|
||||
s.undo().unwrap();
|
||||
for (id, v, p) in &before {
|
||||
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = two_layers(&mut s);
|
||||
let before = selected_page_points(&s, a, b);
|
||||
// Clear of both networks: between the two squares, inside the box.
|
||||
let doc = s.doc().unwrap().clone();
|
||||
assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none());
|
||||
assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside));
|
||||
drag(&mut s, (150.0, 50.0), (170.0, 60.0));
|
||||
for (id, v, p) in &before {
|
||||
assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along");
|
||||
}
|
||||
s.undo().unwrap();
|
||||
click(&mut s, 150.0, 50.0);
|
||||
assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() {
|
||||
// `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected:
|
||||
// the press at the box's centre lands on `a`'s region too.
|
||||
let mut s = Session::new();
|
||||
let mut square = |x: f64, size: f64| {
|
||||
crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap()
|
||||
};
|
||||
let (a, b) = (square(0.0, 300.0), square(100.0, 50.0));
|
||||
ex(&mut s, "view.snapGeometry", json!({"on": false}));
|
||||
ex(&mut s, "view.snapObjects", json!({"on": false}));
|
||||
ex(&mut s, "view.snapPixel", json!({"on": false}));
|
||||
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
|
||||
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]}));
|
||||
ex(&mut s, "vector.edit.select", json!({"id": a, "add": true}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let e = ve(&s);
|
||||
assert!(e.id == a && e.vertices.is_empty() && e.others_selected());
|
||||
assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none());
|
||||
assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press");
|
||||
assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside));
|
||||
let before: Vec<Point> = (0..4).map(|v| page_pt(&s, b, v)).collect();
|
||||
drag(&mut s, (125.0, 125.0), (145.0, 135.0));
|
||||
assert!(ve(&s).regions.is_empty(), "the region isn't selected");
|
||||
for (v, p) in before.iter().enumerate() {
|
||||
assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,10 @@ fn themed() -> Document {
|
||||
add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| {
|
||||
let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK);
|
||||
g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }];
|
||||
n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }];
|
||||
n.fills = vec![
|
||||
solid(0.5, 0.5, 0.5),
|
||||
Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 },
|
||||
];
|
||||
n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)];
|
||||
n.effects = vec![Effect {
|
||||
kind: EffectKind::DropShadow(Shadow {
|
||||
|
||||
@@ -25,10 +25,10 @@ use figcraft_doc::proto::{
|
||||
Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger,
|
||||
};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing,
|
||||
LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind,
|
||||
TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork,
|
||||
WindingRule,
|
||||
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode,
|
||||
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
|
||||
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType,
|
||||
VariableValue, VectorNetwork, WindingRule,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -168,7 +168,12 @@ message PrototypeStartingPoint string name=1, string position=3
|
||||
message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2
|
||||
message GUIDPositionMapEntry GUID id=1, string position=2
|
||||
message GUIDPositionMap GUIDPositionMapEntry[] entries=1
|
||||
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
|
||||
enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3
|
||||
message GridTrackSizeFunction GridTrackSizingType type=1, float value=2
|
||||
message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2
|
||||
message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2
|
||||
message GridTrackSizeMap GridTrackSizeEntry[] entries=1
|
||||
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
|
||||
struct Blob byte[] bytes
|
||||
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
|
||||
";
|
||||
@@ -277,6 +282,37 @@ fn s(x: &str) -> Kv<'static> {
|
||||
fn b(x: bool) -> Kv<'static> {
|
||||
Kv::Bool(x)
|
||||
}
|
||||
|
||||
/// A track's size as the format's enum name and value, when both sizing functions agree (the
|
||||
/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in.
|
||||
fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> {
|
||||
let value = track.value;
|
||||
let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value);
|
||||
match track.sizing {
|
||||
Sizing::Fixed if fits => Some(("FIXED", value)),
|
||||
Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)),
|
||||
Sizing::Hug => Some(("HUG", 0.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't
|
||||
/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets
|
||||
/// `dropped`.
|
||||
fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option<Kv<'static>> {
|
||||
let mut entries = Vec::new();
|
||||
for (track, id) in tracks.iter().zip(ids) {
|
||||
match grid_track_size(track) {
|
||||
Some((kind, value)) => {
|
||||
let size = || o(vec![("type", e(kind)), ("value", fl(value))]);
|
||||
entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))]));
|
||||
}
|
||||
None => *dropped = true,
|
||||
}
|
||||
}
|
||||
(!entries.is_empty()).then(|| o(vec![("entries", arr(entries))]))
|
||||
}
|
||||
|
||||
fn guid(session: u32, local: u32) -> Kv<'static> {
|
||||
o(vec![("sessionID", u(session)), ("localID", u(local))])
|
||||
}
|
||||
@@ -1400,25 +1436,7 @@ impl<'d> Exporter<'d> {
|
||||
("stackReverseZIndex", b(l.reverse_z)),
|
||||
]);
|
||||
if grid {
|
||||
// Instance layers share their main layer's track ids, so unchanged tracks and the cells
|
||||
// their layers sit in still match.
|
||||
let key = self.main_layer(n.id);
|
||||
let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize);
|
||||
let (cols, rows) = match self.tracks.get(&key) {
|
||||
Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()),
|
||||
_ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()),
|
||||
};
|
||||
let map = |ids: &[u32]| {
|
||||
o(vec![(
|
||||
"entries",
|
||||
arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()),
|
||||
)])
|
||||
};
|
||||
f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]);
|
||||
if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) {
|
||||
self.warn("grid tracks of fixed or content size were written as equal tracks");
|
||||
}
|
||||
self.tracks.insert(key, (cols, rows));
|
||||
self.grid_layout(n, l, f);
|
||||
} else {
|
||||
let spread = l.content_align == ContentAlign::SpaceBetween;
|
||||
f.extend([
|
||||
@@ -1429,6 +1447,34 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid layout's track gaps, order and sizes.
|
||||
fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) {
|
||||
// Instance layers share their main layer's track ids, so unchanged tracks and the cells
|
||||
// their layers sit in still match.
|
||||
let key = self.main_layer(n.id);
|
||||
let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize);
|
||||
let (cols, rows) = match self.tracks.get(&key) {
|
||||
Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()),
|
||||
_ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()),
|
||||
};
|
||||
let map = |ids: &[u32]| {
|
||||
o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))])
|
||||
};
|
||||
f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]);
|
||||
let mut dropped = false;
|
||||
let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped);
|
||||
let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped);
|
||||
if dropped {
|
||||
self.warn("grid tracks whose size the format can't hold were written as equal tracks");
|
||||
}
|
||||
for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] {
|
||||
if let Some(v) = sizing {
|
||||
f.push((name, v));
|
||||
}
|
||||
}
|
||||
self.tracks.insert(key, (cols, rows));
|
||||
}
|
||||
|
||||
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
|
||||
/// and are written whatever its parent is.
|
||||
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
|
||||
@@ -2374,7 +2420,7 @@ fn unset(key: &str) -> Option<Kv<'static>> {
|
||||
"styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => {
|
||||
o(vec![("guid", guid(u32::MAX, u32::MAX))])
|
||||
}
|
||||
"variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]),
|
||||
"variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]),
|
||||
"mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false),
|
||||
"effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => {
|
||||
arr(Vec::new())
|
||||
|
||||
@@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint {
|
||||
p.kind
|
||||
}
|
||||
};
|
||||
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) }
|
||||
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 }
|
||||
}
|
||||
|
||||
fn effect(r: &mut Lcg) -> Effect {
|
||||
@@ -1022,6 +1022,81 @@ fn junk_documents_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid track sizes reach the file and come back: fixed px, fractional fill and hug tracks, and a
|
||||
/// track the format has no name for leaves an equal track behind instead of failing the write.
|
||||
#[test]
|
||||
fn grid_track_sizes_round_trip() {
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let grid = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap();
|
||||
{
|
||||
let node = doc.node_mut(grid).unwrap();
|
||||
node.layout = Some(AutoLayout {
|
||||
mode: LayoutMode::Grid,
|
||||
grid_columns: 3,
|
||||
grid_rows: 2,
|
||||
column_tracks: vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()],
|
||||
row_tracks: vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)],
|
||||
..AutoLayout::default()
|
||||
});
|
||||
}
|
||||
let (bytes, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let (back, iw) = import(&bytes, "File").unwrap();
|
||||
assert!(iw.is_empty(), "{iw:?}");
|
||||
let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap();
|
||||
assert_eq!(l.column_tracks, vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]);
|
||||
assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)]);
|
||||
|
||||
// A negative fixed track is no size the format can name: the write still succeeds, the track
|
||||
// falls back to an equal one, and the warning says so.
|
||||
let mut odd = doc.clone();
|
||||
if let Some(l) = odd.node_mut(grid).and_then(|n| n.layout.as_mut()) {
|
||||
l.column_tracks[0] = GridTrack::fixed(-5.0);
|
||||
}
|
||||
let (bytes, warnings) = export(&odd, None).unwrap();
|
||||
assert_eq!(warnings, vec!["grid tracks whose size the format can't hold were written as equal tracks".to_string()]);
|
||||
let (back, _) = import(&bytes, "File").unwrap();
|
||||
let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap();
|
||||
assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal");
|
||||
}
|
||||
|
||||
/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes.
|
||||
#[test]
|
||||
fn instance_grid_tracks_clear_the_mains_sizes() {
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap();
|
||||
let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap();
|
||||
let layout = AutoLayout {
|
||||
mode: LayoutMode::Grid,
|
||||
grid_columns: 2,
|
||||
grid_rows: 1,
|
||||
column_tracks: vec![GridTrack::fixed(120.0); 2],
|
||||
..AutoLayout::default()
|
||||
};
|
||||
doc.node_mut(grid).unwrap().layout = Some(layout.clone());
|
||||
let inst = doc.alloc_id();
|
||||
let mut n = doc.node(main).unwrap().clone();
|
||||
(n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new());
|
||||
doc.insert(n, page, None).unwrap();
|
||||
let mut layer = doc.node(grid).unwrap().clone();
|
||||
(layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid));
|
||||
layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout });
|
||||
doc.insert(layer, inst, None).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "File").unwrap();
|
||||
let tracks = |inside: bool| {
|
||||
let n = back.nodes().find(|n| {
|
||||
n.name == "Grid"
|
||||
&& back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside
|
||||
});
|
||||
n.and_then(|n| n.layout.clone()).unwrap().column_tracks
|
||||
};
|
||||
assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]);
|
||||
assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true));
|
||||
}
|
||||
|
||||
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
|
||||
#[test]
|
||||
fn frame_size_limits_survive_without_auto_layout_parent() {
|
||||
|
||||
@@ -24,7 +24,7 @@ const MAX_DEPTH: usize = 256;
|
||||
/// Most path commands read from one geometry blob.
|
||||
const MAX_COMMANDS: usize = 200_000;
|
||||
const MAX_GRID_TRACKS: usize = 1_000;
|
||||
const MAX_GRID_TRACK_VALUE: f64 = 1e6;
|
||||
pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6;
|
||||
/// Id of "no style".
|
||||
const NO_STYLE: &str = "4294967295:4294967295";
|
||||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||||
|
||||
@@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option<Vec<Paint>> {
|
||||
fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
|
||||
let ty = text(p, "type")?;
|
||||
let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
|
||||
// The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`).
|
||||
let mut color_alpha = 1.0;
|
||||
let kind = match ty {
|
||||
"SOLID" => {
|
||||
let c = p.get("color").and_then(color).unwrap_or(Color::BLACK);
|
||||
opacity *= c.a;
|
||||
color_alpha = c.a;
|
||||
PaintKind::Solid { color: c.with_alpha(1.0) }
|
||||
}
|
||||
"GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)),
|
||||
@@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) })
|
||||
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha })
|
||||
}
|
||||
|
||||
/// Gradient handles are already in the node's unit box (start, end, width), like ours.
|
||||
|
||||
@@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() {
|
||||
let s = &named(&doc, "Solid").fills[0];
|
||||
assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0));
|
||||
assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha");
|
||||
assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces");
|
||||
assert!(!s.visible && s.blend == BlendMode::Screen);
|
||||
let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() };
|
||||
assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0)));
|
||||
|
||||
+22
-19
@@ -91,6 +91,27 @@ pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
||||
polylines_with_limit(path, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
fn flatten_curve_segment(el: PathEl, seg_start: Point, limit: usize, count: &mut usize, cur: &mut Polyline) -> bool {
|
||||
let mut over = false;
|
||||
kurbo::flatten([PathEl::MoveTo(seg_start), el], FLATTEN_TOL, |fel| {
|
||||
if over {
|
||||
return;
|
||||
}
|
||||
if let PathEl::LineTo(p) = fel {
|
||||
*count += 1;
|
||||
if *count > limit {
|
||||
over = true;
|
||||
return;
|
||||
}
|
||||
if cur.points.is_empty() {
|
||||
cur.push(seg_start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
});
|
||||
!over
|
||||
}
|
||||
|
||||
/// [`polylines`] with an explicit sample budget, so tests can use a small one.
|
||||
///
|
||||
/// The budget is enforced *while* flattening, not after it (issue #204): the input element
|
||||
@@ -141,25 +162,7 @@ fn polylines_with_limit(path: &BezPath, limit: usize) -> Option<Vec<Polyline>> {
|
||||
PathEl::QuadTo(_, end) | PathEl::CurveTo(_, _, end) => {
|
||||
// Flatten this one segment on its own, so an over-budget curve aborts
|
||||
// here instead of after the whole path has been flattened.
|
||||
let seg_start = pen;
|
||||
let mut over = false;
|
||||
kurbo::flatten([PathEl::MoveTo(seg_start), *el], FLATTEN_TOL, |fel| {
|
||||
if over {
|
||||
return;
|
||||
}
|
||||
if let PathEl::LineTo(p) = fel {
|
||||
count += 1;
|
||||
if count > limit {
|
||||
over = true;
|
||||
return;
|
||||
}
|
||||
if cur.points.is_empty() {
|
||||
cur.push(seg_start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
});
|
||||
if over {
|
||||
if !flatten_curve_segment(*el, pen, limit, &mut count, &mut cur) {
|
||||
return None;
|
||||
}
|
||||
pen = end;
|
||||
|
||||
@@ -59,7 +59,7 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
}
|
||||
|
||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| payload.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
|
||||
@@ -13,6 +13,8 @@ mod brush_tests;
|
||||
mod ops_tests;
|
||||
mod points;
|
||||
#[cfg(test)]
|
||||
mod rounded_side_strokes_tests;
|
||||
#[cfg(test)]
|
||||
mod sides_tests;
|
||||
pub mod width;
|
||||
#[cfg(test)]
|
||||
@@ -451,6 +453,104 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) ->
|
||||
p
|
||||
}
|
||||
|
||||
/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer
|
||||
/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's
|
||||
/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with
|
||||
/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners
|
||||
/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against
|
||||
/// the outer one, so it is a ring under non-zero too.
|
||||
pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath {
|
||||
let [t, r, b, l] = sides.map(non_negative);
|
||||
if t + r + b + l <= 0.0 {
|
||||
return BezPath::new();
|
||||
}
|
||||
let f = match align {
|
||||
StrokeAlign::Inside => 0.0,
|
||||
StrokeAlign::Center => 0.5,
|
||||
StrokeAlign::Outside => 1.0,
|
||||
};
|
||||
// Clamped like the fill: at most half the shorter side.
|
||||
let max_r = non_negative(w.min(h)) / 2.0;
|
||||
let radii = radii.map(|v| non_negative(v).min(max_r));
|
||||
// Each corner's radius with the weights across and down it, clockwise from the top-left.
|
||||
let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)];
|
||||
let outer = if align == StrokeAlign::Inside {
|
||||
figcraft_geom::rounded_rect(w, h, radii, smoothing)
|
||||
} else {
|
||||
let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) });
|
||||
corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing)
|
||||
};
|
||||
let k = 1.0 - f;
|
||||
let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k);
|
||||
if inner.width() <= 0.0 || inner.height() <= 0.0 {
|
||||
return outer;
|
||||
}
|
||||
let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0))));
|
||||
// The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or
|
||||
// smoothed: then it may leave the outer edge.
|
||||
ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0)
|
||||
}
|
||||
|
||||
/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every
|
||||
/// corner is circular (smoothing is defined for those only).
|
||||
fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath {
|
||||
if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) {
|
||||
return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing);
|
||||
}
|
||||
elliptical_rect(r, corners)
|
||||
}
|
||||
|
||||
fn non_negative(v: f64) -> f64 {
|
||||
if v.is_finite() { v.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd
|
||||
/// ring whose hole runs against it.
|
||||
fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath {
|
||||
if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) {
|
||||
return p;
|
||||
}
|
||||
let mut p = outer;
|
||||
p.extend(hole.reverse_subpaths().elements().iter().copied());
|
||||
p
|
||||
}
|
||||
|
||||
/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one
|
||||
/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii.
|
||||
fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) {
|
||||
let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) });
|
||||
let [tl, tr, br, bl] = c;
|
||||
let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)];
|
||||
let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum));
|
||||
(c.map(|(rx, ry)| (rx * fit, ry * fit)), fit)
|
||||
}
|
||||
|
||||
/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like
|
||||
/// `figcraft_geom::rounded_rect` (circular corners come out the same).
|
||||
fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
// Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at.
|
||||
let quarters = [
|
||||
(Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2),
|
||||
(Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0),
|
||||
(Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2),
|
||||
(Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI),
|
||||
];
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((r.x0 + tl.0, r.y0));
|
||||
for (corner, (sx, sy), (rx, ry), start) in quarters {
|
||||
if rx > 0.0 && ry > 0.0 {
|
||||
let c = Point::new(corner.x + sx * rx, corner.y + sy * ry);
|
||||
p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin()));
|
||||
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e));
|
||||
} else {
|
||||
p.line_to(corner);
|
||||
}
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar
|
||||
//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit,
|
||||
//! smoothing and degenerate weights.
|
||||
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::kurbo::{BezPath, Point, Shape};
|
||||
use figcraft_geom::rounded_rect;
|
||||
|
||||
use crate::{rect_side_strokes, rounded_side_strokes};
|
||||
|
||||
const W: f64 = 120.0;
|
||||
const H: f64 = 60.0;
|
||||
|
||||
/// The area of a ring whose hole runs against its outer edge.
|
||||
fn area(p: &BezPath) -> f64 {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
fn painted(p: &BezPath, x: f64, y: f64) -> bool {
|
||||
p.winding(Point::new(x, y)) % 2 != 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_corners_paint_what_rect_side_strokes_paints() {
|
||||
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
|
||||
let sides = [1.0, 2.0, 3.0, 4.0];
|
||||
let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align));
|
||||
assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `border-l-4 rounded-r-lg`.
|
||||
#[test]
|
||||
fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() {
|
||||
let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside);
|
||||
assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar));
|
||||
assert!(painted(&bar, 2.0, 30.0));
|
||||
for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] {
|
||||
assert!(!painted(&bar, x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_weight_on_every_side_is_the_uniform_ring() {
|
||||
let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside);
|
||||
let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0));
|
||||
assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring));
|
||||
assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_and_outside_grow_the_box_and_its_corners() {
|
||||
for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] {
|
||||
let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align));
|
||||
let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0));
|
||||
let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0));
|
||||
assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole);
|
||||
}
|
||||
}
|
||||
|
||||
/// 8 × 7 inner ellipses at the bottom corners.
|
||||
#[test]
|
||||
fn a_bottom_border_tapers_into_the_bottom_corners() {
|
||||
let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside);
|
||||
assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5));
|
||||
for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] {
|
||||
assert!(!painted(&bottom, x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weight_past_the_radius_leaves_square_inner_corners() {
|
||||
let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside);
|
||||
assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2));
|
||||
assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0));
|
||||
}
|
||||
|
||||
/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the
|
||||
/// hole stays inside the outer edge, across or down.
|
||||
#[test]
|
||||
fn inner_corners_shrink_to_fit_a_narrow_inner_box() {
|
||||
let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside);
|
||||
assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0));
|
||||
assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge");
|
||||
let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside);
|
||||
assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0));
|
||||
assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge");
|
||||
let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside);
|
||||
assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0));
|
||||
assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothed_corners_keep_the_stroke_inside_the_fill() {
|
||||
let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside);
|
||||
let fill = rounded_rect(W, H, [16.0; 4], 0.6);
|
||||
let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05)));
|
||||
let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0));
|
||||
assert_eq!(stray.count(), 0, "painted outside the fill or along the top");
|
||||
assert!(painted(&smooth, 60.0, 59.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_and_outside_strokes_follow_smoothed_corners() {
|
||||
for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] {
|
||||
let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align));
|
||||
let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6));
|
||||
let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6));
|
||||
assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_wider_than_the_box_leave_the_outer_edge() {
|
||||
let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside);
|
||||
assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_weight_paints_nothing() {
|
||||
assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty());
|
||||
let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0];
|
||||
assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty());
|
||||
}
|
||||
@@ -271,3 +271,30 @@ fn sample_files() {
|
||||
std::fs::write(format!("{stem}-{}.pdf", i + 1), export_pdf(&d, &[*f], &PdfOptions::default()).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny PNG whose header claims 30,000 × 30,000 RGBA (3.6 GB decoded) is refused when it is read,
|
||||
/// before anything is decoded: the PDF writer is craft-libs' patched krilla.
|
||||
#[test]
|
||||
fn a_png_that_decodes_too_large_is_refused() {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
}
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&30_000u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&30_000u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, &[0; 64]).unwrap();
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut png, b"IHDR", &ihdr);
|
||||
chunk(&mut png, b"IDAT", &z.finish().unwrap());
|
||||
chunk(&mut png, b"IEND", &[]);
|
||||
const { assert!(krilla::CRAFT_UNWIND_SAFE_DROP) };
|
||||
assert!(krilla::image::Image::from_png(krilla::Data::from(png), true).is_err());
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub struct Manifest {
|
||||
pub main: String,
|
||||
/// The plugin API version the plugin was written for (1 when not given; panels and settings
|
||||
/// need 2, widgets 3).
|
||||
#[serde(default = "api_version")]
|
||||
#[serde(default = "api_version", deserialize_with = "api_from_number_or_string")]
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
pub permissions: Permissions,
|
||||
@@ -61,6 +61,25 @@ fn api_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// `api` as a whole number or a version string (`"1.0.0"`, whose major part is the API version).
|
||||
fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Api {
|
||||
Number(u32),
|
||||
Text(String),
|
||||
}
|
||||
match Api::deserialize(d)? {
|
||||
Api::Number(n) => Ok(n),
|
||||
Api::Text(s) => s
|
||||
.trim()
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|major| major.parse().ok())
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plugin may do.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Permissions {
|
||||
|
||||
@@ -462,3 +462,12 @@ fn bad_manifests_are_refused() {
|
||||
assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_may_be_a_version_string() {
|
||||
assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1);
|
||||
assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2);
|
||||
assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2);
|
||||
assert!(manifest(json!({"api": "4.0.0"})).is_err());
|
||||
assert!(manifest(json!({"api": "x"})).is_err());
|
||||
}
|
||||
|
||||
@@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
// Per-side strokes on rectangles/frames.
|
||||
if let Some(sides) = props.sides
|
||||
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
||||
&& n.corners.uniform() == Some(0.0)
|
||||
{
|
||||
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
|
||||
let c = &n.corners;
|
||||
return Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
});
|
||||
}
|
||||
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
||||
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
|
||||
|
||||
@@ -129,6 +129,27 @@ fn strokes_align() {
|
||||
assert_eq!(px(&pix, 30, 8)[0], 0xF5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_strokes_follow_rounded_corners() {
|
||||
// border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges.
|
||||
let mut d = figcraft_doc::Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap();
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::WHITE)];
|
||||
n.corners.radii = [0.0, 8.0, 8.0, 0.0];
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]);
|
||||
let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap();
|
||||
for x in 10..14 {
|
||||
assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}");
|
||||
}
|
||||
for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] {
|
||||
assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_clips_siblings() {
|
||||
let mut d = figcraft_doc::Document::new("t");
|
||||
|
||||
@@ -377,15 +377,38 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
return;
|
||||
}
|
||||
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero };
|
||||
for r in &mut network.regions {
|
||||
r.winding = rule;
|
||||
let vector = |path: &BezPath, filled: bool| {
|
||||
let mut network = VectorNetwork::from_path(path);
|
||||
for r in &mut network.regions {
|
||||
r.winding = rule;
|
||||
}
|
||||
if !filled {
|
||||
network.regions.clear();
|
||||
}
|
||||
NodeKind::Vector { network }
|
||||
};
|
||||
let at = Affine::translate((bb.x0, bb.y0));
|
||||
// Fill closes every subpath; a stroke only those that close themselves. A filled
|
||||
// and stroked path with a really open subpath becomes a fill layer and a stroke layer.
|
||||
let tol = CLOSE_TOLERANCE * bb.width().max(bb.height());
|
||||
let (snapped, closed) = (closed_for_fill(&path, tol, false), closed_for_fill(&path, tol, true));
|
||||
if let (Some(_), Some(_)) = (fill, stroke)
|
||||
&& snapped != closed
|
||||
{
|
||||
let fill_only =
|
||||
Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height());
|
||||
let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height());
|
||||
let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)];
|
||||
if p.paint_order() == usvg::PaintOrder::StrokeAndFill {
|
||||
layers.reverse();
|
||||
}
|
||||
for n in layers {
|
||||
cx.add(n, parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if fill.is_none() {
|
||||
network.regions.clear();
|
||||
}
|
||||
(NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0)
|
||||
(vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0)
|
||||
}
|
||||
};
|
||||
let fallback = match kind {
|
||||
@@ -395,12 +418,18 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
_ => "Vector",
|
||||
};
|
||||
let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h);
|
||||
n.transform = transform;
|
||||
n.corners.radii = [radius; 4];
|
||||
let target = Target { to_node: transform.inverse() * world, size: (w, h) };
|
||||
n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect();
|
||||
let n = paint_path(cx, p, n, transform, world, true, true);
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
||||
/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke.
|
||||
fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node {
|
||||
n.transform = transform;
|
||||
let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) };
|
||||
n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect();
|
||||
n.strokes.clear();
|
||||
if let Some(s) = stroke {
|
||||
if let Some(s) = p.stroke().filter(|_| with_stroke) {
|
||||
n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect();
|
||||
n.stroke = stroke_props(s, length_scale(world));
|
||||
// A stroke painted under the fill shows only its outer half.
|
||||
@@ -409,7 +438,55 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
n.stroke.weight /= 2.0;
|
||||
}
|
||||
}
|
||||
cx.add(n, parent);
|
||||
n
|
||||
}
|
||||
|
||||
/// How far (as a share of the path's larger side) a subpath may end from its start and still be
|
||||
/// closed onto it: exporters that round coordinates leave closed outlines this far apart.
|
||||
const CLOSE_TOLERANCE: f64 = 1e-3;
|
||||
|
||||
/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all`
|
||||
/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or
|
||||
/// more segments, or one curve) closed with a straight segment.
|
||||
fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath {
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
let mut out = BezPath::new();
|
||||
let mut sub: Vec<PathEl> = Vec::new();
|
||||
let flush = |sub: &mut Vec<PathEl>, out: &mut BezPath| {
|
||||
let start = match sub.first() {
|
||||
Some(PathEl::MoveTo(p)) => *p,
|
||||
_ => {
|
||||
sub.clear();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let open = !matches!(sub.last(), Some(PathEl::ClosePath));
|
||||
let segments = sub.len() - 1;
|
||||
let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..)));
|
||||
if open && (segments >= 2 || curve) {
|
||||
let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol);
|
||||
if near && let Some(last) = sub.last_mut() {
|
||||
match last {
|
||||
PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => *p = start,
|
||||
PathEl::MoveTo(_) | PathEl::ClosePath => {}
|
||||
}
|
||||
}
|
||||
if near || all {
|
||||
sub.push(PathEl::ClosePath);
|
||||
}
|
||||
}
|
||||
for e in sub.drain(..) {
|
||||
out.push(e);
|
||||
}
|
||||
};
|
||||
for e in path.elements() {
|
||||
if matches!(e, PathEl::MoveTo(_)) {
|
||||
flush(&mut sub, &mut out);
|
||||
}
|
||||
sub.push(*e);
|
||||
}
|
||||
flush(&mut sub, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None`
|
||||
|
||||
+30
-5
@@ -79,6 +79,8 @@ struct W<'a> {
|
||||
clip: Option<String>,
|
||||
/// `id` attributes written so far.
|
||||
used_ids: std::collections::BTreeSet<String>,
|
||||
/// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`.
|
||||
next_suffix: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// ` clip-path="url(#…)"` for a clip path id (else nothing).
|
||||
@@ -150,8 +152,15 @@ fn blend_style(b: figcraft_color::BlendMode) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters
|
||||
/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out.
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
s.chars().filter(|&c| xml_char(c)).collect::<String>().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// Whether XML 1.0 allows `c` (see [`esc`]).
|
||||
fn xml_char(c: char) -> bool {
|
||||
!matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')
|
||||
}
|
||||
|
||||
fn hex(c: Color) -> String {
|
||||
@@ -160,8 +169,13 @@ fn hex(c: Color) -> String {
|
||||
|
||||
impl W<'_> {
|
||||
fn id(&mut self, prefix: &str) -> String {
|
||||
self.next += 1;
|
||||
format!("{prefix}{}", self.next)
|
||||
loop {
|
||||
self.next += 1;
|
||||
let id = format!("{prefix}{}", self.next);
|
||||
if self.used_ids.insert(id.clone()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing.
|
||||
@@ -547,9 +561,19 @@ impl W<'_> {
|
||||
|
||||
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).
|
||||
fn unique_id(&mut self, name: &str) -> String {
|
||||
let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect();
|
||||
// As written: what `esc` leaves out isn't part of it.
|
||||
let base: String =
|
||||
name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect();
|
||||
let base = if base.is_empty() { "layer".to_string() } else { base };
|
||||
let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base);
|
||||
let next = self.next_suffix.entry(base.clone()).or_insert(1);
|
||||
let id = loop {
|
||||
let k = *next;
|
||||
*next += 1;
|
||||
let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") };
|
||||
if !self.used_ids.contains(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
self.used_ids.insert(id.clone());
|
||||
id
|
||||
}
|
||||
@@ -1004,6 +1028,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
|
||||
mask: None,
|
||||
clip: None,
|
||||
used_ids: Default::default(),
|
||||
next_suffix: Default::default(),
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -135,6 +135,54 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() {
|
||||
assert!(svg.contains(">He</tspan>") && svg.contains(">l</tspan>") && svg.contains(">lo</tspan>"), "{svg}");
|
||||
}
|
||||
|
||||
/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML:
|
||||
/// live-text export must leave them out so the SVG still parses, for plain and per-run spans.
|
||||
#[test]
|
||||
fn live_text_export_drops_xml_forbidden_control_characters() {
|
||||
use figcraft_doc::{StyleRun, TextData};
|
||||
figcraft_text::scan_system_fonts();
|
||||
for styled in [false, true] {
|
||||
let mut d = Document::new("t");
|
||||
let page = d.first_page().unwrap();
|
||||
let mut text = TextData::new("line one\u{b}line two\u{1}");
|
||||
text.style.font_size = 24.0;
|
||||
if styled {
|
||||
let mut style = text.style.clone();
|
||||
style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
|
||||
text.runs.push(StyleRun { start: 5, end: 12, style });
|
||||
text.normalize();
|
||||
}
|
||||
let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap();
|
||||
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
|
||||
assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}");
|
||||
assert!(svg.contains("line") && svg.contains("two"), "{svg}");
|
||||
let mut d2 = Document::new("t2");
|
||||
let p2 = d2.first_page().unwrap();
|
||||
assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_strokes_on_rounded_corners_export_as_their_area() {
|
||||
// border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd.
|
||||
use figcraft_geom::Shape;
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap();
|
||||
let n = d.get_mut(f).unwrap();
|
||||
n.corners.radii = [0.0, 8.0, 8.0, 0.0];
|
||||
n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
n.stroke.align = figcraft_doc::StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]);
|
||||
let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap();
|
||||
let stroke = svg.split("<path").find(|e| e.contains("#FF0000")).unwrap();
|
||||
assert!(stroke.contains("fill-rule=\"evenodd\""), "{stroke}");
|
||||
let data = stroke.split("d=\"").nth(1).and_then(|s| s.split('"').next()).unwrap();
|
||||
let area = figcraft_geom::BezPath::from_svg(data).unwrap().area().abs();
|
||||
assert!((area - 240.0).abs() < 0.01, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
|
||||
use figcraft_doc::{StrokeJoin, VectorNetwork};
|
||||
@@ -159,3 +207,60 @@ fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
|
||||
assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke");
|
||||
assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_id_is_linear_for_repeated_names() {
|
||||
let d = Document::new("t");
|
||||
let opts = SvgOptions::default();
|
||||
let mut w = W {
|
||||
doc: &d,
|
||||
roots: &[],
|
||||
page: Affine::IDENTITY,
|
||||
defs: String::new(),
|
||||
body: String::new(),
|
||||
next: 0,
|
||||
opts: &opts,
|
||||
mask: None,
|
||||
clip: None,
|
||||
used_ids: Default::default(),
|
||||
next_suffix: Default::default(),
|
||||
};
|
||||
assert_eq!(w.unique_id("a"), "a");
|
||||
assert_eq!(w.unique_id("a_3"), "a_3");
|
||||
assert_eq!(w.unique_id("a"), "a_2");
|
||||
assert_eq!(w.unique_id("a"), "a_4");
|
||||
// Unique as written, without what XML leaves out.
|
||||
assert_eq!(w.unique_id("b\u{fffe}"), "b");
|
||||
assert_eq!(w.unique_id("b\u{ffff}"), "b_2");
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..30_000 {
|
||||
w.unique_id("Rectangle");
|
||||
}
|
||||
assert_eq!(w.unique_id("Rectangle"), "Rectangle_30001");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2), "unique_id is quadratic: {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_ids_never_clash_with_generated_ids() {
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "clip1", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
d.get_mut(f).unwrap().clip = true;
|
||||
d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 50.0, 50.0).unwrap();
|
||||
let svg = export_svg(&d, &[f], &SvgOptions { include_ids: true, ..SvgOptions::default() }).unwrap();
|
||||
assert_eq!(svg.matches("id=\"clip1\"").count(), 1, "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_open_curve_is_closed_for_its_fill() {
|
||||
// SVG fills an open subpath as if closed: one curve and the line back enclose an area.
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><path fill="#000000" d="M0 0C0 50 100 50 100 0"/></svg>"##;
|
||||
import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap();
|
||||
let loops = |n: &figcraft_doc::Node| match &n.kind {
|
||||
NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::<usize>(),
|
||||
_ => 0,
|
||||
};
|
||||
assert_eq!(d.nodes().map(loops).sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
@@ -243,6 +243,27 @@ fn groups_keep_their_ids_and_transforms_bake_into_shapes() {
|
||||
assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_close_open_subpaths() {
|
||||
let (d, f) = imported("open_subpaths");
|
||||
let loops = |n: &figcraft_doc::Node| match &n.kind {
|
||||
NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::<usize>(),
|
||||
_ => 0,
|
||||
};
|
||||
// One filled layer: all four subpaths are fill loops, the 0.001 gap and the triangle included.
|
||||
let filled = layer(&d, f, "filled");
|
||||
assert_eq!((filled.kind.type_name(), loops(filled), filled.strokes.len()), ("VECTOR", 4, 0));
|
||||
// Filled and stroked with an open triangle: a fill layer under the stroke, which stays open.
|
||||
let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked"));
|
||||
assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0));
|
||||
assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1));
|
||||
// Only near-closed ends: one layer, closed, stroke and all.
|
||||
let near = layer(&d, f, "near");
|
||||
assert_eq!((loops(near), near.fills.len(), near.strokes.len()), (1, 1, 1));
|
||||
let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect();
|
||||
assert_eq!(order, ["filled", "stroked fill", "stroked", "near", "under", "under fill"], "paint order: fill then stroke, or the stroke first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_shapes_stay_rectangles_ellipses_and_lines() {
|
||||
let (d, f) = imported("primitives");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user