diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f..30d819a2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,13 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU +# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks. +[target.'cfg(all(windows, target_env = "gnu"))'] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3b03a99c..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefdd..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 96dff6a9..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. + +## egui dependency font icons + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | diff --git a/Cargo.lock b/Cargo.lock index fabd85f8..9a3896b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -258,7 +253,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -700,9 +695,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -753,9 +748,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -836,6 +831,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -849,7 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -862,7 +867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -902,14 +907,38 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" + +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.1", +] [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ - "egui", "wayland-client", ] @@ -1032,6 +1061,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -1124,10 +1159,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1135,9 +1175,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1159,6 +1198,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1210,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1190,9 +1229,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,30 +1248,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1245,9 +1286,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1265,9 +1305,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1292,22 +1331,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 +1395,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1374,16 +1412,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1398,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1465,9 +1504,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.10.0" +version = "0.11.2" dependencies = [ "arboard", + "craft-update", "craft-window", "eframe", "egui", @@ -1491,7 +1531,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1545,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1561,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1584,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1553,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1605,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1633,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1603,12 +1643,12 @@ dependencies = [ "serde", "serde_json", "sha2", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-geom" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1616,7 +1656,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1664,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1680,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1689,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1706,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1719,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "ctrlc", @@ -1716,12 +1756,12 @@ dependencies = [ "toml", "tungstenite", "wat", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-svg" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,10 +1791,11 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", + "craft-update", "egui", "egui_extras", "figcraft-color", @@ -1777,7 +1818,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.10.0" +version = "0.11.2" dependencies = [ "eframe", "egui", @@ -1791,6 +1832,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -2033,9 +2084,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2061,9 +2114,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", @@ -2281,9 +2334,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -2295,9 +2348,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -2308,9 +2361,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2322,15 +2375,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2343,15 +2396,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2613,7 +2666,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2756,9 +2809,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -2897,6 +2950,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3252,6 +3311,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3264,9 +3329,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3288,7 +3353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3364,9 +3429,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", @@ -3375,9 +3440,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", @@ -3385,9 +3450,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", @@ -3398,9 +3463,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", ] @@ -3460,6 +3525,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3544,6 +3622,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3621,6 +3705,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3881,9 +3974,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -3932,7 +4025,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3950,6 +4043,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3959,6 +4064,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.16" @@ -4036,6 +4168,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4059,6 +4200,29 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4070,6 +4234,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4256,9 +4430,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4442,6 +4616,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4449,10 +4633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4518,6 +4702,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4571,9 +4785,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -4587,9 +4801,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] @@ -4722,6 +4936,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4811,6 +5031,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4838,7 +5087,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "simplecss", "siphasher", "skrifa 0.44.0", @@ -4851,6 +5100,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4865,9 +5120,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4876,9 +5131,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", @@ -4891,9 +5146,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", @@ -4901,6 +5156,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5209,7 +5474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5247,9 +5512,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5262,10 +5527,19 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.10" +name = "webpki-root-certs" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -5475,7 +5749,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5693,8 +5967,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5705,7 +5978,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5751,6 +6024,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -5761,6 +6044,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5881,7 +6170,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-engine", "serde_json", @@ -5889,9 +6178,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6076,9 +6365,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -6087,9 +6376,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -6111,6 +6400,23 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6190,3 +6496,23 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 65809315..2be79ed1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -32,11 +32,15 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } +# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the -# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it +# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would +# pull a second egui family version). +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -79,9 +83,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" @@ -122,14 +126,32 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" 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 = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } + # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -[patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } diff --git a/NOTICE b/NOTICE index a0baaa27..9970631d 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..8581dbaa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -132,6 +132,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. +- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. @@ -142,6 +145,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **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-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **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. @@ -328,6 +335,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | +| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | +| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 3fe17e80..0c04b732 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -20,6 +20,10 @@ impl Args { let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b87aa431..a03d5b26 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::process::ExitCode; use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; @@ -272,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { return Err(CliError::Failed("the page is empty: nothing to export".into())); } let mut used = std::collections::BTreeSet::new(); + let mut next_suffix: std::collections::HashMap = std::collections::HashMap::new(); for id in ids { let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer")); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base} {k}"); - k += 1; - } + let base_lower = base.to_lowercase(); + let next = next_suffix.entry(base_lower).or_insert(1); + let name = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") }; + if used.insert(candidate.to_lowercase()) { + break candidate; + } + }; let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned(); write(&path, &bytes_of(&[id])?)?; println!("{path}"); @@ -313,7 +318,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -338,7 +343,8 @@ pub fn parse_script(text: &str) -> Result, String> { Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -356,31 +362,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe None => Session::new(), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -506,6 +511,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 66627673..ba516a83 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -108,15 +108,24 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -127,13 +136,13 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = @@ -141,7 +150,7 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -149,19 +158,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -170,7 +179,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -185,7 +194,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -195,10 +204,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index b1aeb54a..1db451ba 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -13,6 +13,38 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index baf1882b..2ea939c3 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -120,15 +120,15 @@ impl Abuse { open } - /// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones - /// before it; enough wrong ones in a row lock the address out. + /// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong + /// ones before it (they only decay with time); enough wrong ones lock the address out. pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) { if !ok { self.wrong_codes.take(now); } for (k, times) in ip_blocks(ip) { let a = self.address(k, f64::from(times), now); - if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { + if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { a.wrong = 0; } if !ok { diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 6392e552..1e440a32 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() { let t0 = Instant::now(); let me = ip("198.51.100.9"); let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }); - // A right code forgives the wrong ones before it. + // A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones. + let a = ip("2001:db8:1:2::1"); + let b = ip("2001:db8:1:2::2"); for _ in 0..WRONG_CODES - 1 { + abuse.code_tried(a, false, t0); + } + abuse.code_tried(b, true, t0); + abuse.code_tried(a, true, t0); + assert_eq!(abuse.code_locked(a, t0), None); + abuse.code_tried(a, false, t0); + assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT)); + // Wrong codes: locked for the lockout, then free again. + for _ in 0..WRONG_CODES { abuse.code_tried(me, false, t0); } - abuse.code_tried(me, true, t0); - for _ in 0..WRONG_CODES - 1 { - abuse.code_tried(me, false, t0); - } - assert_eq!(abuse.code_locked(me, t0), None); - // One more: locked for the lockout, then free again. - abuse.code_tried(me, false, t0); assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT)); assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some()); assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None); diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index d32a1747..312b4aea 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option { /// A snapshot file's bytes. pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { - let body = message::encode(state).map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + let mut out = Vec::with_capacity(SNAP_HEADER); out.extend_from_slice(SNAP_MAGIC); out.push(FORMAT); out.extend_from_slice(&generation.to_le_bytes()); - out.extend_from_slice(&crc(&body).to_le_bytes()); - out.extend_from_slice(&body); + out.extend_from_slice(&0u32.to_le_bytes()); + let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?; + let sum = crc(&out[SNAP_HEADER..]); + out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes()); Ok(out) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ab65c2d8..31b877cb 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1480,14 +1480,18 @@ impl Backend for Store { } fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { - let _ = flush_log(file, log); - log.dirty + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) }); - if dirty { + if compact { self.compact(file, server); } - // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + // It closes anyway, with its state in the snapshot and durable log. self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index d8c5b915..93a872ee 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() { assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); } +#[test] +fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() { + let dir = TempDir::new("read-only-reconnects"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, original) = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Read only"); + let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(); + (id, bytes) + }; + let mut last_client = 0; + for _ in 0..6 { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let mut server = store.open(&id).unwrap(); + let client = join(&mut server, Access::View); + assert!(client.id() > last_client, "issued ids survive closing and reopening the store"); + last_client = client.id(); + store.changed(&id, &mut server); + // Idle must sync even if no periodic flush ran before the connection closed. + assert!(store.idle(&id, &mut server)); + assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot"); + assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert!(!log.entries.is_empty()); + assert_eq!(server.seq(), 0); + } +} + #[test] fn a_long_log_is_compacted_while_open() { let dir = TempDir::new("compact"); @@ -252,6 +280,19 @@ fn temporary_directories_are_removed() { assert!(!path.exists()); } +#[test] +fn snapshot_encoding_keeps_the_existing_framing_and_checksum() { + let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap(); + let body = figcraft_collab::message::encode(&state).unwrap(); + let mut legacy = b"FCSN\x01".to_vec(); + legacy.extend_from_slice(&37u64.to_le_bytes()); + legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes()); + legacy.extend_from_slice(&body); + let encoded = disk::encode_snapshot(37, &state).unwrap(); + assert_eq!(encoded, legacy); + assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state)); +} + #[test] fn random_bytes_never_panic_the_readers() { let mut rng = Rng::new(7); diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index f08ca90f..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,7 +69,15 @@ 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. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `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); } @@ -117,7 +125,7 @@ impl Services for WebServices { } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; - if let Err(e) = window.open_with_url_and_target(url, "_blank") { + if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") { log::error!("couldn't open {url}: {e:?}"); } } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 6196d08d..c13c8832 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -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 } @@ -33,6 +33,7 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col # The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language # (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad). [target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } # Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs index 64f97835..6ed8a047 100644 --- a/apps/figcraft/build.rs +++ b/apps/figcraft/build.rs @@ -1,6 +1,7 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack //! (no-op elsewhere). fn main() { + println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)"); println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "windows" { diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index ff9c2fbe..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use figcraft_ui_egui::ControlRequest; +use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState}; use serde_json::{Value, json}; type Queue = Arc>>; @@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60); /// core busy until it recovers. const ACCEPT_RETRY: Duration = Duration::from_millis(100); +/// Why a request's transport wait ended without a control reply. +#[derive(Debug, PartialEq, Eq)] +enum WaitFailure { + Timeout, + /// The UI side dropped its sender before replying (the app is closing). + Disconnected, +} + +/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses. +/// A commit that got the gate first has already queued its reply: consume that reply instead of +/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing. +fn wait_for_reply(rx: &std::sync::mpsc::Receiver, gate: &RequestGate, timeout: Duration) -> Result { + match rx.recv_timeout(timeout) { + Ok(reply) => Ok(reply), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected), + } +} + +/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically +/// against a gate whose commit already happened. +fn after_timeout(rx: &std::sync::mpsc::Receiver, gate: &RequestGate) -> Result { + match gate.cancel_if_open() { + RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected), + RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout), + } +} + /// 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) { target.retain(|request| !request.is_cancelled()); @@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; @@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = v.get("params").cloned().unwrap_or(json!({})); let (tx, rx) = std::sync::mpsc::channel(); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = RequestGate::new(); let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner); pending.retain(|request| !request.is_cancelled()); if pending.len() >= MAX_QUEUED_REQUESTS { @@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?; continue; } - pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() }); + pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() }); drop(pending); if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { c.request_repaint(); } - match rx.recv_timeout(timeout) { + match wait_for_reply(&rx, &gate, timeout) { Ok(r) => match r.result { Ok(res) => json!({"id": r.id, "ok": true, "result": res}), Err(e) => json!({"id": r.id, "ok": false, "error": e}), }, - Err(_) => { - cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + Err(WaitFailure::Timeout) => { json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } + Err(WaitFailure::Disconnected) => { + json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"}) + } } } Err(e) => { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 011b0613..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { - ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() } + ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } #[test] @@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. +#[test] +fn a_committed_reply_wins_over_the_transport_timeout() { + let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); + let path = dir.join("shot.png"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel(); + // The UI's final commit gets the gate first: the file and reply are both committed before the + // transport's timeout branch can look at the gate. + let committed = gate.commit(|| { + std::fs::write(&path, b"png").unwrap(); + tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap(); + }); + assert!(committed.is_some()); + assert!(path.is_file()); + let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.is_ok()); + assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit"); + assert!(path.is_file(), "a winning commit keeps its file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. +#[test] +fn a_timeout_cancels_an_open_request() { + let gate = RequestGate::new(); + let (_tx, rx) = mpsc::channel::(); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout))); + assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); +} + +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. +#[test] +fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel::(); + drop(tx); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected))); + assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision"); +} + #[test] fn idle_and_partial_frame_connections_expire() { for bytes in [b"".as_slice(), b"{\"id\":"] { @@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; @@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { // Include expired requests in the socket queue, too: admission must reclaim their slots. pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| { let r = request(); - r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + r.state.cancel_if_open(); r })); let (mut client, server) = pair(); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0b71149a..6c9bdf93 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit +// reports a removed UI Automation node. Catch build configurations that drop the workaround. +#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))] +compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive"); + #[cfg(target_os = "macos")] mod apple_events; mod args; @@ -62,6 +67,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -71,7 +79,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 +96,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)); } } @@ -117,6 +133,11 @@ impl eframe::App for App { } fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { @@ -199,6 +220,7 @@ fn main() -> eframe::Result { // open (no frame then). figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); + fig.set_startup_update_check(true); fig.automation_roots = roots; fig.ui.home = false; // Files named on the command line are the user's too: their missing fonts are listed. diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 06b0cbeb..605a04ca 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -206,10 +206,12 @@ impl Recovery { let doc = d.doc.clone(); let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: - // the server, the file's id, then the session, client id and last change), while it + // the server, the file's id, then the session, client id, last change and the secret the + // client id resumes with), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. - let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); + let online = + s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret)); let job = move || { if !write_atomic(&path, &doc, &versions, &branches) { return false; @@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?); let mut at = at.split(' ').map(|n| n.parse::().ok()); let (session, client, seq) = (at.next()??, at.next()??, at.next()??); - let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq}); + // Absent in notes written before ids had secrets. + let secret = at.next().flatten().unwrap_or(0); + let params = + json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret}); s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } @@ -338,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec { fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true).mode(0o600); + let Ok(mut f) = opts.open(&tmp) else { return false }; + if std::io::Write::write_all(&mut f, &bytes).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + } + #[cfg(not(unix))] + { + if std::fs::write(&tmp, bytes).is_err() { + return false; + } + } + std::fs::rename(&tmp, path).is_ok() } /// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index c9036773..f877a0d4 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let left = crash(&s, &root); let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap(); let at = s.online_of(s.docs[0].uid).unwrap().2; - assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had"); + assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had"); assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret"); let (mut next, script, mut r, opened) = restart(&s, &root); assert_eq!(opened, 0, "nothing comes back as a plain copy"); diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 7ee41ada..d9cd53ea 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -2,8 +2,8 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use figcraft_engine::{Prefs, Session}; use figcraft_ui_egui::{DialogFuture, FigApp, Services}; @@ -20,6 +20,8 @@ pub struct Desktop { language: Option, /// Recovery copies of unsaved files (written at once after a panic). recovery: crate::recovery::Shared, + /// The folder of the last file or folder a dialog picked, where the next one starts. + last_folder: Arc>>, } pub fn config_dir() -> Option { @@ -54,7 +56,15 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } + Self { + clipboard: arboard::Clipboard::new().ok(), + dir, + prefs, + reduce_motion, + language: system_language(), + recovery, + last_folder: Arc::default(), + } } } @@ -262,19 +272,57 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { } } +/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder +/// picked itself. +pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option { + let folder = if is_folder { Some(path) } else { path.parent() }; + folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf) +} + +/// The remembered folder, unless it is gone since: then the system's own choice. +pub(crate) fn start_folder(last: Option) -> Option { + last.filter(|f| f.is_dir()) +} + +impl Desktop { + /// A dialog that starts in the last folder used. The Linux portal does not remember it between + /// dialogs as macOS and Windows do. + fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog { + let dialog = dialog(title); + match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) { + Some(folder) => dialog.set_directory(folder), + None => dialog, + } + } +} + +/// `pick`, with the folder of what it picks kept in `last` for the next dialog. +pub(crate) fn remembering(last: Arc>>, pick: DialogFuture, is_folder: bool) -> DialogFuture { + Box::pin(async move { + let path = pick.await?; + if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) { + *last = Some(folder); + } + Some(path) + }) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).add_filter("Supported files", exts).pick_file()) + let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - let mut dialog = dialog(title).set_file_name(name); + let mut dialog = self.dialog_in_last_folder(title).set_file_name(name); for (label, extensions) in save_filters(exts) { dialog = dialog.add_filter(label, extensions); } - picked(dialog.save_file()) + let pick = picked(dialog.save_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_folder(&mut self, title: &str) -> DialogFuture { - picked(dialog(title).pick_folder()) + let pick = picked(self.dialog_in_last_folder(title).pick_folder()); + remembering(self.last_folder.clone(), pick, true) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2abec5bb..970f1729 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); let _ = std::fs::remove_dir_all(&dir); } + +/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself. +#[test] +fn the_next_dialog_starts_in_the_folder_of_the_last_pick() { + use crate::services::{folder_to_remember, remembering, start_folder}; + use std::path::PathBuf; + let dir = root("last-folder"); + let file = dir.join("Poster.figcraft"); + assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder"); + assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself"); + assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder"); + + let last = Arc::new(Mutex::new(None::)); + let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file))); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered"); + let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false); + assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None)); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder"); + + assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone())); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own"); +} diff --git a/contributors/people.toml b/contributors/people.toml index 6a92a2a9..7491a05c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -76,3 +76,7 @@ sync_github_name = true real_name = "Gert Pellin" display_name = "Gert Pellin" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..4cf10f6a 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() { let mut late = session("Cy"); late.execute("collab.join", &json!({"code": shown})).unwrap(); pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. })); - assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status); + // The port is free again: another program's host may have taken it, and that host refuses + // the code's secret. + let label = late.live().status.label(); + assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}"); } #[test] @@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index cc3be839..43b0b7b8 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -189,9 +189,9 @@ pub(crate) enum Taken { /// in answer must go out on this connection. pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { match m { - ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => { + ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => { if let Ok(mut r) = resume.lock() { - *r = Some(Resume { session, client_id, seq }); + *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). let peers = peers.clone(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index d5a38d9d..fe9b4782 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -488,6 +488,21 @@ fn silent_connections_time_out() { host.stop(); } +#[test] +fn connections_without_a_hello_are_told_why_they_end() { + // A socket's read timeout can end a moment before the hello's deadline; the connection still + // gets the refusal. Twelve at once, so that happens to some of them. + 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(); + std::thread::scope(|s| { + let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect(); + for w in waits { + assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time")); + } + }); + host.stop(); +} + #[test] fn guests_reconnect_after_a_network_failure() { let (mut host, mut hp, invite) = start(); @@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() { let (mut host, mut hp, invite) = start_with(limits, Limits::default()); let probe = Probe::new(); let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into())); - cfg.limits = limits; + // The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds + // the wait for the welcome, which a loaded machine can take longer than 600 ms to send. + cfg.limits.keepalive = limits.keepalive; let _g = Guest::start(cfg, probe.inbox.clone()).unwrap(); let mut gp = probe; gp.wait("welcome", welcomed); @@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { } /// 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. +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own +/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close +/// and a slow poke loop don't delay it. fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option { 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 mut watch = r.0.get_ref().try_clone().unwrap(); let t0 = Instant::now(); + let (ended_tx, ended) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + let until = Instant::now() + WAIT; + loop { + match watch.read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {} + Err(_) => break, + } + } + let _ = ended_tx.send(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; - } - _ => {} + if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) { + closed = Some(at.saturating_duration_since(t0)); + break; } } host.stop(); + let _ = reader.join(); closed } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index c6628662..43e56001 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -195,17 +195,25 @@ impl Duplex { impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { - if let Some(d) = self.deadline { - let left = d.saturating_duration_since(Instant::now()); - if left.is_zero() { - return Err(io::ErrorKind::TimedOut.into()); + let n = loop { + 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))?; } - 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), - }?; + let read = match &self.tls { + Some(t) => tls::read(t, &mut self.read, &self.write, buf), + None => self.read.read(buf), + }; + match read { + // The socket's timeout can run out a moment before the deadline (it counts whole + // milliseconds): wait out the rest, so a read times out only once it has passed. + Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {} + read => break read?, + } + }; if let Some(m) = self.meter.as_mut() { m.add(n)?; } diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 27523fce..4a2579f8 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); @@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() { host.execute("doc.switch", &json!({"index": 0})).unwrap(); assert_eq!(host.live_summary()["link"], a["link"]); assert_eq!(host.live_summary()["document"], 0); + // Each joined file opens in front when its welcome arrives, and the two joins race: the file + // whose welcome came last is in front. + assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front"); + // Concurrent joins may complete in either order. Put B in front explicitly so the following + // assertions exercise A's background session independent of network timing. + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); + assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits"); // An edit in A reaches the guest's copy of A only, while B is in front at the guest. - assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 57bb9cf2..b273ba34 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Measurements => measurements, Board => board, } diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 8f2a9841..a84a4ffd 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -41,6 +41,8 @@ pub struct Client { /// The session joined (see [`ServerMsg::Welcome`]). session: u64, id: u32, + /// What this client resumes its id with (see [`Resume::secret`]). + secret: u64, /// The server's state as of `seq`. confirmed: Replica, seq: u64, @@ -84,7 +86,7 @@ impl Client { /// Start from the server's `Welcome` (which must carry a snapshot). pub fn join(welcome: ServerMsg) -> Result { - let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else { + let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else { return Err(Error::Protocol("expected a welcome with the document".into())); }; let mut confirmed = Replica::from_snapshot(&snapshot)?; @@ -93,6 +95,7 @@ impl Client { Ok(Client { session, id: client_id, + secret, view: confirmed.clone(), confirmed, seq, @@ -415,9 +418,10 @@ impl Client { ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello }) } - /// What a reconnect resumes with: this client's session, id and last sequence number. + /// What a reconnect resumes with: this client's session, id, last sequence number and the + /// secret its id was given with. pub fn resume(&self) -> Resume { - Resume { session: self.session, client_id: self.id, seq: self.seq } + Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret } } /// Handle a message from the server. Returns messages to send: batches that were waiting for @@ -458,10 +462,11 @@ impl Client { Ok(Vec::new()) } ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())), - ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => { + ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => { if session != self.session || client_id != self.id { - return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access }); + return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret }); } + self.secret = secret; self.access = access; self.rejoin(seq, catch_up, &processed, peers, recompute) } diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 9f5d12fb..e4b48aac 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -11,6 +11,16 @@ use crate::message::{ use crate::sim::Rng; use crate::{Error, Replica, diff}; +#[test] +fn encoding_into_a_prefix_keeps_the_wire_body() { + let msg = moved_layer(); + let prefix = b"container header".to_vec(); + let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap(); + assert_eq!(&bytes[..prefix.len()], prefix); + assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap()); + assert_eq!(decode::(&bytes[prefix.len()..], bytes.len()).unwrap(), msg); +} + /// A typical edit: one layer dragged (its transform). fn moved_layer() -> ClientMsg { let mut doc = Document::new("f"); @@ -38,7 +48,7 @@ fn samples() -> (Vec, Vec) { name: "Ada".into(), color: "#FF8800".into(), client_version: "0.2.0".into(), - resume: Some(Resume { session: 77, client_id: 4, seq: 10 }), + resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }), }), moved_layer(), cursor(), @@ -102,6 +112,7 @@ fn samples() -> (Vec, Vec) { processed: Processed::default(), peers: vec![peer.clone()], access: Access::Edit, + secret: 0x5EC2E7, }, ServerMsg::Welcome { session: 77, @@ -111,6 +122,7 @@ fn samples() -> (Vec, Vec) { processed: Processed { last_batch: 7, refused: vec![3, 6] }, peers: vec![], access: Access::View, + secret: 1, }, ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }), ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] }, diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 9b35e05d..e4ff32c2 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -132,18 +132,15 @@ impl List { let keys: Vec = value.read("order")?; let list = (self.get_mut)(doc); let mut slots: Vec> = std::mem::take(list).into_iter().map(Some).collect(); - let mut at: BTreeMap> = BTreeMap::new(); + let mut at: BTreeMap> = BTreeMap::new(); for (i, e) in slots.iter().enumerate() { if let Some(e) = e { - at.entry(e.key()).or_default().push(i); + at.entry(e.key()).or_default().push_back(i); } } for k in keys { let Some(idx) = at.get_mut(&k) else { continue }; - if idx.is_empty() { - continue; - } - let i = idx.remove(0); + let Some(i) = idx.pop_front() else { continue }; if let Some(e) = slots.get_mut(i).and_then(Option::take) { list.push(e); } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 5132ce75..acd40397 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -6,11 +6,12 @@ //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items //! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype -//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides -//! changing different items of the list (or one adding items at its end) is no conflict, only the -//! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the -//! other side's changes to it are left out. Everything else from both sides is kept. +//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by +//! item: both sides changing different items of the list (or one adding items at its end) is no +//! conflict, only the same item changed both ways or the list shortened on a side while the other +//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for +//! one side, and the other side's changes to it are left out. Everything else from both sides is +//! kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[ // Not `CodeLinks`: there is one per language, and lining them up by position could leave two // for one language (the second hidden), so they merge whole. Prop::DevResources, + Prop::Measurements, ]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4fda776a..a6346162 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -94,6 +94,10 @@ pub struct Resume { pub client_id: u32, /// The last sequence number it applied. pub seq: u64, + /// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only + /// the client the id was given to can resume it (0: none). + #[serde(default)] + pub secret: u64, } /// Someone in the session. @@ -635,7 +639,8 @@ pub enum ServerMsg { /// Joined: the session's identity (a server that restarted without its saved state is a new /// session), this connection's client id, the current sequence number, the document (or the /// batches missed since a reconnecting client's last sequence number), what the server did - /// with this client's batches so far, who else is here and what this client may do. + /// with this client's batches so far, who else is here, what this client may do and the + /// secret it resumes its id with ([`Resume::secret`]; only this client is told). Welcome { session: u64, client_id: u32, @@ -644,6 +649,8 @@ pub enum ServerMsg { processed: Processed, peers: Vec, access: Access, + #[serde(default)] + secret: u64, }, /// Accepted changes, in order, to everyone (the author's own come back as confirmation). Changes(Accepted), @@ -732,11 +739,13 @@ pub fn utc_date(secs: u64) -> String { /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. pub fn encode(msg: &T) -> Result, Error> { - let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?; - let mut out = Vec::with_capacity(body.len() + 1); - out.push(WIRE_VERSION); - out.extend(body); - Ok(out) + encode_into(msg, Vec::new()) +} + +/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer. +pub fn encode_into(msg: &T, mut prefix: Vec) -> Result, Error> { + prefix.push(WIRE_VERSION); + postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string())) } /// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most diff --git a/crates/collab/src/reconnect_tests.rs b/crates/collab/src/reconnect_tests.rs index 95ebbb2b..5c7c4205 100644 --- a/crates/collab/src/reconnect_tests.rs +++ b/crates/collab/src/reconnect_tests.rs @@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() { } assert!(snapshots > 0, "no reconnect needed a snapshot"); } + +#[test] +fn resume_needs_the_issued_secret() { + let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() }); + let mut host = join(&mut s); + let guest = join(&mut s); + // A participant who knows the (public) session and the host's id can't take that id over, + // whether the session is full or not, and with view-only access or not. + let forged = message::Resume { client_id: host.id(), ..guest.resume() }; + assert_eq!(forged.session, host.resume().session); + let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View); + assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}"); + s.leave(guest.id()); + let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap(); + assert_ne!(id, host.id(), "a forged resume joins as new"); + // The real client still comes back as itself. + let resend = come_back(&mut s, &mut host); + assert!(resend.is_empty()); +} diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38ed3de8..52f112ad 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; use crate::change::{Change, Entry, Prop, Target}; -use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals}; +use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals}; use crate::replica::{Mode, Replica, create_change}; use crate::{Error, Limits, docprops, same_secret}; @@ -61,6 +61,9 @@ pub struct Server { /// Every client id handed out (a reconnecting client keeps its id) and what was done with /// its batches. issued: BTreeMap, + /// The secret each client id resumes with (sent only in that client's welcome). Not saved: + /// after a restart, the first to come back for an id that isn't connected sets it. + secrets: BTreeMap, /// The latest accepted batches, oldest first (a reconnecting client gets what it missed). log: VecDeque, /// Changes in `log`, and about how many bytes it holds ([`weight`]). @@ -116,6 +119,7 @@ impl Server { deleted: BTreeSet::new(), removed: BTreeSet::new(), issued: BTreeMap::new(), + secrets: BTreeMap::new(), log: VecDeque::new(), log_changes: 0, log_bytes: 0, @@ -219,6 +223,7 @@ impl Server { && let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k)) { self.issued.remove(&old); + self.secrets.remove(&old); } } @@ -266,7 +271,7 @@ impl Server { if !same_secret(&hello.token, &self.token) { return Err(refuse("wrong session token")); } - let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id)); + let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r)); if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) { return Err(refuse("the session is full")); } @@ -282,15 +287,17 @@ impl Server { Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()), _ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?), }; - let id = match resume { - Some(r) => r.client_id, + let (id, secret) = match resume { + Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)), None => { let id = self.next_client; self.next_client += 1; self.issued.insert(id, Processed::default()); + let secret = new_session_id().max(1); + self.secrets.insert(id, secret); self.forget_oldest(); self.note(Journal::Issued { client: id }); - id + (id, secret) } }; let processed = self.issued.get(&id).cloned().unwrap_or_default(); @@ -298,7 +305,7 @@ impl Server { let signals = reads_signals(&hello.client_version); let peer = Peer { client_id: id, name: hello.name, color: hello.color }; let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect(); - let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access }; + let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret }; self.peers.insert(id, peer.clone()); if access == Access::View { self.viewers.insert(id); @@ -318,6 +325,16 @@ impl Server { Ok((id, outs)) } + /// Whether `r` may take its client id back: it carries the secret that id was given with, or + /// (after a restart, which forgets secrets) a secret for an id nobody is connected as. + fn may_resume(&self, r: &Resume) -> bool { + r.secret != 0 + && match self.secrets.get(&r.client_id) { + Some(s) => *s == r.secret, + None => !self.peers.contains_key(&r.client_id), + } + } + /// Client `id`'s connection closed. pub fn leave(&mut self, id: u32) -> Vec { self.presence.remove(&id); @@ -541,11 +558,14 @@ impl Server { nodes.extend(t.write(self.state.value(&t)?.as_ref())); } } + let mut restored = std::collections::BTreeSet::new(); for id in gone { match doc.node(id) { Some(_) => { for d in doc.subtree(id) { - if let Some(n) = doc.node(d) { + if restored.insert(d) + && let Some(n) = doc.node(d) + { nodes.push(create_change(n, self.state.key(d))?); } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fff7667..7ce1df5e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -122,7 +122,8 @@ impl Color { /// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits /// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`). pub fn from_hex(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); if !s.chars().all(|c| c.is_ascii_hexdigit()) { return None; } @@ -355,7 +356,7 @@ impl ImageFilters { } /// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity. - pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [ + pub const PRESETS: [(&str, &str, ImageFilters); 7] = [ ("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }), ("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }), ("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }), diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 2216b6fa..6fedad83 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -115,6 +115,15 @@ fn infinite_channels_clamp() { assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } +#[test] +fn hex_allows_at_most_one_leading_hash() { + assert!(Color::from_hex("##ff0000").is_none()); + assert!(Color::from_hex("###fff").is_none()); + assert!(Color::from_hex_or_name("##ff0000").is_none()); + assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000"); +} + #[test] fn malformed_alpha_or_extra_args_rejected() { assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index d480f4ea..93d32f0b 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -35,6 +35,9 @@ impl Effect { impl TextStyle { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f)); + if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) { + f(c); + } } } diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 3a97a5d0..5cadfbbe 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() { assert_eq!(back.color_profile, ColorProfile::DisplayP3); assert_eq!(back.image_presets, d.image_presets); } + +#[test] +fn text_decoration_colour_is_visited() { + let mut s = TextStyle::default(); + s.set_look(|l| l.color = Some(RED)); + s.for_each_color_mut(&mut |c| *c = Color::BLACK); + assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited"); +} diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..1ee885f5 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option { | NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing), NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), - NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), - NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]), + NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing), + NodeKind::Star { count, inner_ratio } => { + figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing) + } NodeKind::Line => { let mut p = BezPath::new(); p.move_to((0.0, 0.0)); @@ -126,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask /// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there. -fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { +pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { if !doc.has_masks() { return false; } diff --git a/crates/doc/src/handoff.rs b/crates/doc/src/handoff.rs index 9700b56c..04bf5e95 100644 --- a/crates/doc/src/handoff.rs +++ b/crates/doc/src/handoff.rs @@ -71,8 +71,38 @@ impl Document { if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) { Arc::make_mut(r).parent = now.parent; } + // A damaged file can give a historical layer itself (or an ancestor) as a child; a copy + // with a cycle under the root would send every walk of it round forever. + if !d.is_tree_under(root) { + return None; + } d.dev_snapshots.clear(); d.dirty = crate::Dirty::default(); Some(d) } + + /// Whether every layer under `root` is reached once (no layer is its own descendant or listed + /// by two parents), walking iteratively and stopping at the first repeat. + fn is_tree_under(&self, root: NodeId) -> bool { + let mut seen = BTreeSet::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + return false; + } + let Some(n) = self.nodes.get(&id) else { + return false; + }; + for &c in &n.children { + let Some(child) = self.nodes.get(&c) else { + return false; + }; + if child.parent != Some(id) { + return false; + } + stack.push(c); + } + } + true + } } diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs index 7fc26898..79b97194 100644 --- a/crates/doc/src/handoff_tests.rs +++ b/crates/doc/src/handoff_tests.rs @@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() { doc.dev_snapshots.clear(); assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none()); } + +#[test] +fn a_snapshot_with_a_layer_inside_itself_is_refused() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut snap = doc.snapshot_subtree(f).unwrap(); + // A damaged file: the historical frame lists itself as a child. + let mut bad = (*snap.nodes[0]).clone(); + bad.children.push(f); + snap.nodes[0] = Arc::new(bad); + doc.dev_snapshots.insert(f, Arc::new(snap)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() { + use crate::NodeId; + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap(); + let snap = doc.snapshot_subtree(f).unwrap(); + + let mut missing = snap.clone(); + let mut bad_root = (*missing.nodes[0]).clone(); + bad_root.children.push(NodeId(99999)); + missing.nodes[0] = Arc::new(bad_root); + doc.dev_snapshots.insert(f, Arc::new(missing)); + assert!(doc.with_dev_snapshot(f).is_none()); + + let mut nonrecip = snap.clone(); + let mut bad_child = (*nonrecip.nodes[1]).clone(); + bad_child.parent = Some(NodeId(88888)); + nonrecip.nodes[1] = Arc::new(bad_child); + doc.dev_snapshots.insert(f, Arc::new(nonrecip)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn validate_is_linear_in_a_wide_parent() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + for i in 0..5_000 { + doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap(); + } + assert!(doc.validate().is_empty()); + let kid = doc.node(page).unwrap().children[0]; + doc.get_mut(page).unwrap().children.retain(|c| *c != kid); + assert!(doc.validate().iter().any(|m| m.contains("not listed in parent"))); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c556b9..9ff1d968 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -856,6 +856,12 @@ impl Document { { *component = *m; } + // A measurement copied with the layer it measures to measures to the copy; one to a + // layer left behind isn't copied. + c.measurements.retain_mut(|m| match m.to { + Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(), + None => true, + }); // A connector (or label) copied with what it joins joins the copies. if let Some(b) = c.board.as_mut() { b.remap(map); @@ -1017,6 +1023,10 @@ impl Document { } } let mut issues = Vec::new(); + // Every (parent, child) edge and the page list, indexed once so each node's checks are + // lookups: a wide parent or a long page list doesn't make validation quadratic. + let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect(); + let page_ids: std::collections::BTreeSet = self.pages.iter().copied().collect(); for (id, n) in self.nodes.iter() { if n.id != *id { note(&mut issues, || format!("node key {id} has id {}", n.id)); @@ -1033,12 +1043,12 @@ impl Document { } } if let Some(p) = n.parent - && !self.node(p).is_some_and(|pn| pn.children.contains(id)) + && !(self.node(p).is_some() && edges.contains(&(p, *id))) { 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)); + let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { note(&mut issues, || format!("page list mismatch for {id}")); } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 649d5e56..2a284b05 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -237,6 +237,17 @@ pub struct DevResource { pub name: String, } +/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another +/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers' +/// current bounds, so it follows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Measurement { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, +} + /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -386,6 +397,11 @@ impl NodeKind { self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) } + /// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it). + pub fn has_corner_smoothing(&self) -> bool { + self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. }) + } + pub fn text(&self) -> Option<&TextData> { match self { NodeKind::Text(t) => Some(t), @@ -518,6 +534,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// Dev Mode measurements that start from this layer, in the order they were added. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub measurements: Vec, /// On a board: what this object is (a sticky note, a connector, a stamp…). #[serde(default, skip_serializing_if = "Option::is_none")] pub board: Option, @@ -631,6 +650,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + measurements: Vec::new(), board: None, } } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a646ae62..f6187589 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -795,7 +795,7 @@ fn yes() -> bool { impl Effect { /// Effect types in the order the Design panel lists them: (label, type name). - pub const TYPES: [(&'static str, &'static str); 7] = [ + pub const TYPES: [(&str, &str); 7] = [ ("Drop shadow", "dropShadow"), ("Inner shadow", "innerShadow"), ("Layer blur", "layerBlur"), diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..61b3628e 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -1003,7 +1003,7 @@ impl PrototypeSettings { ]; /// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it). - pub const CUSTOM_DEVICE: &'static str = "Custom size"; + pub const CUSTOM_DEVICE: &str = "Custom size"; /// A new custom size. pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0]; /// The largest side a custom size may have (points). diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1a006c26..41e233c4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -559,3 +559,70 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } + +#[test] +fn text_insert_at_run_boundary_keeps_runs_disjoint() { + let mut t = TextData::new("0123456789"); + t.restyle(0, 5, |s| s.font_size = 20.0); + t.restyle(5, 10, |s| s.font_size = 30.0); + t.replace_range(5, 5, "xx"); + let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect(); + assert_eq!(runs, vec![(0, 7), (7, 12)]); +} + +#[test] +fn many_small_vector_subpaths_build_and_stroke_without_rescans() { + // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) + // and a big rounded polygon (#F-333): each used to scan the whole network per step. + let mut p = figcraft_geom::kurbo::BezPath::new(); + for i in 0..3_000 { + let x = f64::from(i) * 10.0; + p.move_to((x, 0.0)); + p.line_to((x + 5.0, 0.0)); + p.line_to((x + 5.0, 5.0)); + p.line_to((x, 0.0)); + p.close_path(); + } + let net = VectorNetwork::from_path(&p); + assert_eq!(net.vertices.len(), 3 * 3_000); + assert_eq!(net.regions[0].loops.len(), 3_000); + assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); + assert_eq!(net.stroke_runs().len(), 3_000); + + let pts: Vec = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect(); + let chain = VectorNetwork::from_points(&pts, false); + let runs = chain.stroke_runs(); + assert_eq!(runs.len(), 1); + assert!(!runs[0].closed); + + let ring: Vec = (0..4_000) + .map(|i| { + let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU; + Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 }) + }) + .collect(); + let mut poly = VectorNetwork::from_points(&ring, true); + for v in &mut poly.vertices { + v.radius = 0.5; + } + let rounded = poly.rounded().unwrap(); + assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides"); + assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len()); +} + +#[test] +fn two_segment_closed_loop_rounded_corners() { + let mut v0 = Vertex::new(0.0, 0.0); + v0.radius = 2.0; + let mut v1 = Vertex::new(10.0, 0.0); + v1.radius = 2.0; + let net = VectorNetwork { + vertices: vec![v0, v1], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }], + regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }], + }; + let r = net.rounded().unwrap(); + assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); + let path = net.fill_path(); + assert!(!path.elements().is_empty()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 458426ad..d2abfed9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -579,7 +579,14 @@ impl TextData { } self.runs.sort_by_key(|r| r.start); let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for r in std::mem::take(&mut self.runs) { + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } match merged.last_mut() { Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, _ => merged.push(r), diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 1165bd60..b3040b1e 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -134,6 +134,16 @@ pub struct VectorNetwork { pub regions: Vec, } +/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`, +/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths. +fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) { + if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i)) + && s.b == from + { + s.b = to; + } +} + impl VectorNetwork { /// A network from a polyline / polygon. pub fn from_points(points: &[Point], closed: bool) -> Self { @@ -169,11 +179,7 @@ impl VectorNetwork { if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() { return; } - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(net, loop_segs, a, f); net.vertices.pop(); all_loops.push(std::mem::take(loop_segs)); }; @@ -219,11 +225,7 @@ impl VectorNetwork { let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6); if same && a != f { // Merge the duplicate end vertex into the first one. - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(&mut net, &loop_segs, a, f); if a + 1 == net.vertices.len() { net.vertices.pop(); } @@ -385,6 +387,7 @@ impl VectorNetwork { s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); } + let mut by_vertex: std::collections::BTreeMap = std::collections::BTreeMap::new(); for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); @@ -400,15 +403,28 @@ impl VectorNetwork { } out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb }); let arc = out.segments.len() - 1; - // The arc goes between the two segments wherever a loop walks through the point. - for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { - let n = lp.len(); - let between = - |k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1)); - if let Some(k) = (0..n).find(|k| between(*k)) { - lp.insert(k + 1, arc); + by_vertex.insert(c.v, arc); + } + // The arc goes between the two segments wherever a loop walks through the point. Each loop + // is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic. + for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { + let Some(&first_idx) = lp.first() else { continue }; + let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { + (Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b, + (Some(first), _) => first.a, + _ => 0, + }; + let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len())); + let mut cur = start_v; + for &x in lp.iter() { + rebuilt.push(x); + let Some(s) = self.segments.get(x) else { continue }; + cur = if s.a == cur { s.b } else { s.a }; + if let Some(&arc) = by_vertex.get(&cur) { + rebuilt.push(arc); } } + *lp = rebuilt; } Some(out) } @@ -547,25 +563,40 @@ impl VectorNetwork { } } let degree = |v: usize| degrees.get(v).copied().unwrap_or(0); - let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { - self.segments.iter().enumerate().find_map(|(i, s)| { - if used.get(i).copied().unwrap_or(true) { - return None; + // Each vertex's segments in segment order, and how far down that list the used ones reach, + // so finding the next unused segment never rescans the whole network. + let mut adjacent: Vec> = vec![Vec::new(); self.vertices.len()]; + for (i, s) in self.segments.iter().enumerate() { + if let Some(l) = adjacent.get_mut(s.a) { + l.push((i, false)); + } + if s.b != s.a + && let Some(l) = adjacent.get_mut(s.b) + { + l.push((i, true)); + } + } + let mut cursor = vec![0usize; self.vertices.len()]; + let mut remaining = self.segments.len(); + let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { + let list = adjacent.get(v)?; + let at = cursor.get_mut(v)?; + while let Some(&(i, rev)) = list.get(*at) { + if !used.get(i).copied().unwrap_or(true) { + return Some((i, rev)); } - if s.a == v { - Some((i, false)) - } else if s.b == v { - Some((i, true)) - } else { - None - } - }) + *at += 1; + } + None }; let stop = |v: usize| degree(v) != 2 || breaks(v); // Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops). let mut starts: Vec = (0..self.vertices.len()).filter(|v| stop(*v)).collect(); starts.extend((0..self.vertices.len()).filter(|v| !stop(*v))); for v0 in starts { + if remaining == 0 { + break; + } while let Some((si, rev)) = next_from(v0, &used) { let Some(start) = self.vertices.get(v0) else { break }; let mut path = BezPath::new(); @@ -574,8 +605,11 @@ impl VectorNetwork { let mut closed = false; let mut seg = Some((si, rev)); while let Some((i, r)) = seg { - if let Some(u) = used.get_mut(i) { + if let Some(u) = used.get_mut(i) + && !*u + { *u = true; + remaining = remaining.saturating_sub(1); } let Some(s) = self.segments.get(i) else { break }; self.push_seg(&mut path, s, r); diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 4659a597..a7a4d1d7 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() { s.undo().unwrap(); assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); } + +#[test] +fn a_size_given_with_a_new_path_fixes_that_axis() { + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap()); + set(&mut s, v, json!({"sizingH": "fill"})); + assert_eq!(sizing(&s, v).0, Sizing::Fill); + set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30})); + assert_eq!(sizing(&s, v).0, Sizing::Fixed); + assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v)); +} diff --git a/crates/engine/src/batch.rs b/crates/engine/src/batch.rs new file mode 100644 index 00000000..5c4550ec --- /dev/null +++ b/crates/engine/src/batch.rs @@ -0,0 +1,192 @@ +//! Step references in `command.batch`: a step's params can use what an earlier step returned. +//! +//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}` +//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either +//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the +//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::{Result, bad}; + +/// Deepest params nesting the resolver walks before giving up. +const MAX_DEPTH: usize = 64; + +/// One step of a batch: a command, its params and the name later steps can call it by. +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStep { + pub command: String, + pub params: Value, + /// The step's `as`, for `{"$ref": name}` in later steps. + pub name: Option, +} + +impl BatchStep { + /// A step without a name. + pub fn new(command: impl Into, params: Value) -> Self { + Self { command: command.into(), params, name: None } + } +} + +/// A step's `as` (`step` is its index, for the error): `None` when it has none. +pub fn step_name(step: usize, json: &Value) -> Result> { + match json.get("as") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())), + Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))), + } +} + +/// The steps of one batch run so far: their results, and which step each `as` name belongs to. +/// A nested `command.batch` step gets its own scope, so its references are to its own steps. +#[derive(Clone, Debug, Default)] +pub struct BatchScope { + names: HashMap, + results: Vec, +} + +impl BatchScope { + /// The scope for running `steps`. Two steps with the same name fail here, before any step runs. + pub fn new(steps: &[BatchStep]) -> Result { + let mut names = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + let Some(name) = &step.name else { continue }; + if let Some(first) = names.insert(name.clone(), i) { + return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); + } + } + Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + } + + /// The results of the steps run so far, in order. + pub fn results(&self) -> &[Value] { + &self.results + } + + pub fn into_results(self) -> Vec { + self.results + } + + /// The index of the step that runs next. + pub fn next_step(&self) -> usize { + self.results.len() + } + + pub(crate) fn push(&mut self, result: Value) { + self.results.push(result); + } + + /// `params` of the next step with every reference in it replaced. + pub(crate) fn resolve(&self, params: &Value) -> Result { + self.resolve_in(params, 0) + } + + fn resolve_in(&self, v: &Value, depth: usize) -> Result { + let step = self.next_step(); + if depth > MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) => { + let mut out = Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } + } + + /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. + fn reference(&self, o: &Map) -> Result { + let step = self.next_step(); + let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); + let key = if o.contains_key("$step") { "$step" } else { "$ref" }; + if let Some(k) = o.keys().find(|k| *k != key && *k != "field") { + return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`"))); + } + let (n, who) = if key == "$step" { + let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?; + let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + fail(format!("`$step` {n} must be an earlier step ({range})")) + })?; + (n, format!("step {n}")) + } else { + let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?; + let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?; + if n >= step { + return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step"))); + } + (n, format!("step {n} (\"{name}\")")) + }; + let path = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(fail("`field` must be a string".into())), + }; + let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; + lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + } + + fn known_names(&self) -> String { + let mut names: Vec<_> = self.names.iter().collect(); + names.sort_by_key(|(_, i)| **i); + if names.is_empty() { + return "no step has an `as` name".into(); + } + let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect(); + list.join(", ") + } +} + +/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in +/// JSON Pointer is allowed). The error is the detail after "has no `path`". +fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + // A key as written first, so a key holding `/` still works as it did before paths. + if let Some(x) = v.get(path) { + return Ok(x); + } + let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" }; + walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}")) +} + +fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + let mut cur = v; + let mut walked = String::new(); + for seg in path.split('/') { + let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") }; + cur = match cur { + Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?, + Value::Array(a) => match seg.parse::() { + Ok(i) => a.get(i).ok_or_else(|| { + let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) }; + format!(": {here} has {items}") + })?, + Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")), + }, + _ => return Err(format!(": {here} is {}", kind(cur))), + }; + if !walked.is_empty() { + walked.push('/'); + } + walked.push_str(seg); + } + Ok(cur) +} + +fn kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index ac69118e..451a6300 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() { let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); } + +/// Runs `commands` as a batch and returns the error message, which must be `BadParams`. +fn batch_error(s: &mut Session, commands: serde_json::Value) -> String { + match s.execute("command.batch", &json!({"commands": commands})) { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_named_step_is_referred_to_by_its_name() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}}, + {"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3])); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them. +#[test] +fn a_field_path_indexes_into_a_list_in_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}}, + {"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}}, + {"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}} + ]}), + ) + .unwrap(); + let copies = results[2]["ids"].as_array().unwrap().clone(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too"); + assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy"); +} + +#[test] +fn a_field_path_walks_nested_objects_and_takes_whole_values() { + let mut scope = crate::BatchScope::default(); + scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"})); + let resolved = scope + .resolve(&json!({ + "x": {"$step": 0, "field": "bounds/x"}, + "r": {"$step": 0, "field": "bounds/corners/0/r"}, + "all": {"$step": 0, "field": "ids"}, + "key": {"$step": 0, "field": "a/b"}, + "pointer": {"$step": 0, "field": "/ids"} + })) + .unwrap(); + assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key"); +} + +#[test] +fn bad_names_and_paths_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}}); + let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}); + let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}}); + let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}}); + for (commands, needle) in [ + (json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"), + (json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"), + (json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"), + (json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"), + ( + json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]), + "is step 1, not an earlier step", + ), + (json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"), + (json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"), + (json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"), + (json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"), + (json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"), + ( + json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]), + "step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item", + ), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"), + (json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"), + ] { + let msg = batch_error(&mut s, commands); + assert!(msg.contains(needle), "{msg:?} should contain {needle:?}"); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn text_that_looks_like_a_named_reference_is_left_alone() { + let mut s = Session::new(); + let text = "{\"$ref\": \"card\"} $ref"; + let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text)); +} + +/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the +/// outer batch can't see its. +#[test] +fn a_nested_batch_has_its_own_names() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "as": "inner", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one"); + assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list"); + + let msg = batch_error( + &mut s, + json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}]), + ); + assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); +} diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..0180504c 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result { let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect(); items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum(); - let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() }; - let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 }; + let Some(first) = items.first() else { return ok() }; + let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max); + let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 }; let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64); let mut pos = if horizontal { first.1.x0 } else { first.1.y0 }; for (i, b) in items.clone() { @@ -494,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { t.paragraph_spacing *= k; t.paragraph_indent *= k; t.list_spacing *= k; + if let Some(tp) = &mut t.path { + tp.scale(k, k); + } } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); + for v in &mut network.vertices { + v.radius *= k; + } } n.width *= k; n.height *= k; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 78609771..428738d1 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -217,14 +217,14 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -1151,7 +1151,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { } let layer_node = layer.and_then(|l| doc.node(l)).cloned(); let def = match t.to_ascii_lowercase().as_str() { - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, "text" => PropDef::Text { default: string(p, "default") .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) @@ -1255,7 +1256,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi } _ => {} } - doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } Ok(()) } @@ -1424,6 +1430,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { rename_prop_refs(doc, *c, &name, Some(nn))?; } } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } ok() } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 33c611a9..a6e7932a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,6 +167,42 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// A vector's geometry made from SVG path data (see [`path_network`]). +pub(crate) struct PathShape { + /// The network, with the path's own box moved to the origin and scaled to the size asked for. + pub network: VectorNetwork, + pub width: f64, + pub height: f64, + /// Where the path's own box starts in its coordinates (unscaled). + pub offset: (f64, f64), +} + +/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves +/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse +/// or draws nothing is refused. +pub(crate) fn path_network(cmd: &str, d: &str, width: Option, height: Option) -> Result { + use figcraft_geom::Shape; + let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area"); + let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; + let bb = path.bounding_box(); + if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) { + return Err(bad(cmd, "`path` has coordinates too large to draw")); + } + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(empty()); + } + path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } + let network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(empty()); + } + Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) }) +} + /// How much a vector's path is scaled to take the size asked for: with both sides, each scales /// independently (as resizing a vector does); with one, the other keeps its proportion. A size on /// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 @@ -229,27 +265,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { - let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; - use figcraft_geom::Shape; - let bb = path.bounding_box(); - if bb.width() <= 1e-9 && bb.height() <= 1e-9 { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } - path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; - if sx != 1.0 || sy != 1.0 { - path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - } - *network = VectorNetwork::from_path(&path); - if network.segments.is_empty() { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } + let shape = path_network(cmd, &d, asked.0, asked.1)?; + *network = shape.network; if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width() * sx; - h = bb.height() * sy; - offset = (bb.x0, bb.y0); + (w, h, offset) = (shape.width, shape.height, shape.offset); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); if pts.len() < 2 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5ed92aec..c3514ac5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,11 +4,11 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use figcraft_geom::Affine; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{BatchStep, EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -216,7 +216,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), - spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent), + spec!( + "node.reparent", + "Move layer into", + "{ids?, parent, index? (final position among parent's children, bottom first; default top)}", + true, + reparent + ), spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| { let i = req_id("node.reorder", p, "id")?; let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize; @@ -336,15 +342,16 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); - let cmds: Vec<(String, Value)> = match get(p, "commands") { + let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { let mut out = Vec::new(); for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; - out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({})))); + let params = c.get("params").cloned().unwrap_or(json!({})); + out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } out } @@ -535,6 +542,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(()) } @@ -594,6 +602,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { @@ -610,8 +622,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { 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); @@ -1169,6 +1191,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({"matches": hits})) } +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + fn set(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.set", s, p)?; let props = match get(p, "props") { @@ -1199,34 +1224,44 @@ fn set(s: &mut Session, p: &Value) -> Result { None => Vec::new(), }; let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Auto layout keys go to the layers that can have auto layout; with none among the targets, - // the first refuses them (`props::layout_mut`). + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let mut without_layout = props.clone(); - let mut skipped = BTreeSet::new(); - if !layout_keys.is_empty() { - let doc = &s.ds()?.doc; - let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()); - if ids.iter().any(frame_like) { - skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect(); - without_layout.retain(|k, _| !layout_keys.contains(&k.as_str())); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; } - if !skipped.is_empty() { - let names: Vec = - skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: only frames, components and instances have auto layout", - layout_keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); } let doc = s.doc_mut()?; let before = super::styles::bound_before(doc, &ids); for i in ids { - let props = if skipped.contains(&i) { &without_layout } else { &props }; - super::props::apply("node.set", doc, i, props)?; + super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; } super::styles::detach_edited(doc, &before); if let Some(o) = pivot { @@ -1312,9 +1347,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result { let index = num(p, "index").map(|i| i.max(0.0) as usize); let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - // The layers land together under the first sibling at or above `index` that stays put (none: on - // top), wherever they came from. - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied()); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); for i in &ids { // Counted with the layer still in place, as `reparent` expects. let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ec579d51..97419c60 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -63,10 +63,17 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -77,7 +84,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -855,37 +862,59 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; write(&path, &bytes)?; ds.doc = snapshot.doc; ds.path = Some(path.clone()); - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -893,7 +922,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -907,8 +936,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -941,14 +971,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O .reduce(|a, b| a.union(b)) } -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, -/// and always for slices) every top-level layer of their page, so whatever shows in the area is in. -fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); - match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { - Some(page) if slice || !setting.contents_only => page.children.clone(), - _ => super::edit::z_sorted(doc, ids), +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return super::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see @@ -1136,6 +1189,20 @@ fn size_param(cmd: &str, p: &Value) -> Result> { /// Most files one `file.exportLayers` writes. const MAX_EXPORT_FILES: usize = 1000; +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} /// One file of a layer export: the layer, its setting and the file name. pub struct ExportJob { @@ -1207,6 +1274,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1226,12 +1296,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` @@ -1578,6 +1654,25 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); @@ -1745,3 +1840,18 @@ mod staging_write_tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod zip_cap_tests { + use super::add_member; + + #[test] + fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); + } +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index da1301ec..5a0a4000 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -19,6 +19,9 @@ pub fn specs() -> Vec { )] } +/// Same cap the layout pass applies to grid track counts. +const MAX_TRACKS: usize = 1000; + fn set_track(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layout.setTrack"; let ids = req_targets(CMD, s, p)?; @@ -38,7 +41,7 @@ fn set_track(s: &mut Session, p: &Value) -> Result { let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else { return Err(bad(CMD, format!("node {id} is not a grid auto layout frame"))); }; - let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize); + let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS); if !(0.0..count as f64).contains(&index) { return Err(bad(CMD, format!("`index` must be 0..{count}"))); } @@ -249,6 +252,17 @@ mod tests { assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err()); } + #[test] + fn set_track_clamps_hostile_grid_counts() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid"}}))); + // A crafted file can carry a count node.set would have clamped. + s.doc_mut().unwrap().get_mut(f).unwrap().layout.as_mut().unwrap().grid_columns = 5_000_000; + let p = json!({"ids": [f], "axis": "columns", "index": 4_999_999, "sizing": "hug"}); + assert!(s.execute("layout.setTrack", &p).is_err()); + assert!(node(&s, f).layout.unwrap().column_tracks.len() <= 1000); + } + #[test] fn layout_props_survive_hostile_values() { let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")]; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60d08963..c7b2c5b3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,7 @@ fn registry() -> &'static Registry { list.extend(crate::code_connect::specs()); list.extend(crate::annotations::specs()); list.extend(crate::dev_resources::specs()); + list.extend(crate::dev_measurements::specs()); list.extend(crate::distance::specs()); list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); @@ -109,6 +110,8 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::datafill::specs()); + list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 2eedab45..9ee499fc 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \ listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ -fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ +fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \ +path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \ +node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -142,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option { pub(crate) fn line_height(v: &Value) -> Option { match v { Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto), - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LineHeight::Pixels), } @@ -150,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option { pub(crate) fn letter_spacing(v: &Value) -> Option { match v { - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LetterSpacing::Pixels), } @@ -382,7 +388,7 @@ fn paint_none(v: &Value) -> bool { /// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { - "width" | "height" | "w" | "h" => 0, + "path" | "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, @@ -391,16 +397,35 @@ fn apply_order(k: &str) -> u8 { } } +/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing +/// into W or H. +fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { + let n = doc.get_mut(id)?; + if is_w { + n.child_layout.sizing_h = Sizing::Fixed; + } else { + n.child_layout.sizing_v = Sizing::Fixed; + } + Ok(()) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); keys.sort_by_key(|(k, _)| apply_order(k)); + // Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it). + let mut path_offset = (0.0, 0.0); for (k, v) in keys { let k = k.as_str(); match k { + "path" => path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the // frame it is in (groups have no space of their own). let n = doc.get(id)?; @@ -442,13 +467,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; @@ -939,6 +958,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<(f64, f64)> { + let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?; + let n = doc.get(id)?; + if !matches!(n.kind, NodeKind::Vector { .. }) { + return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name()))); + } + if crate::components::inside_instance(doc, id) { + return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name))); + } + let size = |long: &str, short: &str| -> Result> { + props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose() + }; + let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?; + let n = doc.get_mut(id)?; + if let NodeKind::Vector { network } = &mut n.kind { + *network = shape.network; + } + n.width = shape.width; + n.height = shape.height; + Ok(shape.offset) +} + /// The override group a property key belongs to. pub fn override_group(k: &str) -> &'static str { match k { @@ -1235,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value { let _ = Color::BLACK; v } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_percent_strings_are_rejected() { + for s in ["NaN%", "inf%", "-inf%"] { + assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}"); + assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}"); + } + assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0)); + } +} diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..23e24aac 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -155,6 +155,17 @@ pub fn specs() -> Vec { ok() } ), + spec!( + "proto.linkNotice", + "Notice before leaving for a link", + "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}", + false, + |s, p| { + let show = boolean(p, "show").unwrap_or(true); + s.prefs.link_notice_skipped = !show; + Ok(json!({"shown": show})) + } + ), ] } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..813a6036 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { } /// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and -/// the path layer is removed. +/// the path layer is removed. The path's effects move to the text when it has any, and its fills +/// when one of them shows, each with its style and variable bindings; otherwise the text keeps +/// its own. fn on_path(s: &mut Session, p: &Value) -> Result { const CMD: &str = "text.onPath"; let doc = s.doc()?; @@ -144,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); + let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); + let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let moved = |k: &str| match super::lists::list_binding(k) { + Some((super::lists::BoundList::Fills, ..)) => fills.is_some(), + Some((super::lists::BoundList::Effects, ..)) => effects.is_some(), + _ => false, + }; + let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +163,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(text)?; n.transform = transform; (n.width, n.height) = (w, h); + n.bound.retain(|k, _| !moved(k)); + n.bound.extend(bound); + if let Some((fills, style)) = fills { + (n.fills, n.styles.fill) = (fills, style); + } + if let Some((effects, style)) = effects { + (n.effects, n.styles.effect) = (effects, style); + } if let NodeKind::Text(t) = &mut n.kind { t.path = Some(Box::new(tp)); } @@ -165,7 +183,7 @@ pub fn specs() -> Vec { spec!( "text.onPath", "Text on path", - "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}", + "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}", true, on_path ), diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index f3b9a820..eee312c2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(&doc.variables, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "css" => css(doc, &colls, false), + "tailwind" => css(doc, &colls, true), + _ => return Err(bad(cmd, "format: json|css|tailwind")), }; match string(p, "path") { Some(path) => { @@ -66,26 +67,40 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(vars: &Variables, colls: &[&Collection]) -> String { - let mut out = String::new(); +fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { + let vars = &doc.variables; + // Tailwind theme names, unique per file and the same ones page code reads. + let theme = crate::tailwind::Theme::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; + // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and + // font weight don't. + let roles = crate::token_roles::Roles::new(doc); + let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() }; for c in colls { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { - let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + let sel = match i { + // `static`: Tailwind otherwise drops theme variables no utility uses, and pages also + // read them through `var()` and `(--name)` utilities. + 0 if tailwind => "@theme static".to_string(), + 0 => ":root".to_string(), + _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), + }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { Some(VariableValue::Color(col)) => col.to_css(), - Some(VariableValue::Number(x)) => trim_num(*x), + Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x), + Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { - Some(t) => format!("var({})", css_var_of(t)), + Some(t) => format!("var({})", name(t)), None => continue, }, None => continue, }; - out.push_str(&format!(" {}: {value};\n", css_var_of(v))); + out.push_str(&format!(" {}: {value};\n", name(v))); } out.push_str("}\n"); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 17f71b49..a81a222c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -535,6 +535,9 @@ pub fn specs() -> Vec { spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), + spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| { + tool(s, ToolId::TextOnPath) + }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7ed81b30..c26084f5 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; @@ -40,7 +40,14 @@ pub fn specs() -> Vec { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); - let code = code_for(doc, id, lang); + let mobile = match get(p, "mobile") { + None | Some(serde_json::Value::Null) => None, + Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), + }; + let code = match (lang, mobile) { + (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), + _ => code_for(doc, id, lang), + }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, @@ -61,18 +68,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). + Tailwind, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", + CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -83,11 +94,13 @@ impl CodeLang { CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", + CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } - /// Languages whose lengths are CSS pixels (they can be shown in rem). + /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths + /// are utility classes). pub fn is_web(self) -> bool { matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } @@ -109,7 +122,12 @@ pub(crate) fn num(v: f64) -> String { return "0".into(); } let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + if (r - r.round()).abs() < 1e-9 { + // `as i64` saturates past 2^63, so larger whole values are printed from the float. + if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) } + } else { + format!("{r}") + } } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end @@ -194,6 +212,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), + CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -316,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String { /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] -pub(crate) struct Tokens(pub Vec<(String, String)>); +pub(crate) struct Tokens { + vars: Vec<(String, String)>, + /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. + pub tailwind: bool, + /// The file's theme names (computed on first use). + theme: Option, +} impl Tokens { /// `var(--name, value)`, remembering the variable. - fn var(&mut self, variable: &Variable, value: String) -> String { - let name = css_var_of(variable); + fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { + let name = + if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; let out = format!("var({name}, {value})"); - if !self.0.iter().any(|(n, _)| *n == name) { - self.0.push((name, value)); + if !self.vars.iter().any(|(n, _)| *n == name) { + self.vars.push((name, value)); } out } /// A `:root` rule declaring the variables (empty without any). pub fn root_rule(&self) -> String { - if self.0.is_empty() { + if self.vars.is_empty() { return String::new(); } - let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); + let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } @@ -365,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String { /// `value`, or a reference to the variable bound to the first of `fields` that has one. fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { - Some(v) => t.var(v, value), + Some(v) => t.var(doc, v, value), None => value, } } @@ -659,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> { /// Each layer's path in the tree (names with sibling counts), class and declarations, so /// variants can be compared layer by layer. pub rules: Vec<(String, String, String)>, + /// Instances as their own (overridden) layers instead of component calls, except those + /// connected to code (page code). + pub inline: bool, + /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. + pub fluid: bool, + /// With `fluid`, other layers at least this share of the root's width are fluid too (a + /// divider, a banner image); smaller ones (icons) keep their size. Default: none. + pub fluid_leaves: f64, } impl<'c, 'a> Markup<'c, 'a> { @@ -673,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> { root_class: None, root_attrs: String::new(), rules: Vec::new(), + inline: false, + fluid: false, + fluid_leaves: f64::INFINITY, } } @@ -754,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> { } } // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Page code inlines instances, except those connected to code (codeConnect.set): their + // snippet, e.g. a design-system `"}), + ); + let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"}))); + ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"})); + for text in ["Agendar diagnóstico", "Ver exemplos"] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p}))); + ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}})); + } + let out = code(&mut s, p, json!({})); + assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}"); + assert!(out.contains("`, with each import once at the top. A design + system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme + utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` + declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`, + `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what + layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the + same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the + desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex` + (auto layout frames) or `lg:block` (other layers). - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI @@ -1392,6 +1479,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or opened by the engine (the app opens one when it is clicked). `get_node` and `inspect_document` list them as `devResources`; the Inspect tab lists, adds, edits and removes them. +- **Measurements kept on the canvas:** distances a developer leaves on the canvas for everyone. + `devMode.addMeasurement {from? (default the first selected layer), to?, side?}` → `{from, index, + lines}` places one between `from` and `to` (a layer on the same page), along one side of `from` + (`side`: `top`, `right`, `bottom` or `left`: that side's length) or, with neither, to the layer + around `from`; `devMode.removeMeasurement {from?, index?}` → `{removed}` (no index: every one + starting from the layer); both undoable. `devMode.measurements {page?, id?}` → `[{from, fromName, + index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}]` lists those on a + page (default the current one; `id`: only those from or to that layer), with their lines in page + space measured from the layers' current bounds, so they follow the layers. A measurement is + stored on the layer it starts from (`get_node` lists them as `measurements: [{to} | {side}]`), + saved with the file and synced; the same two layers are measured once (either way round), a + layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed + or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied + with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them + (with nothing selected, every one on the page), selects a measurement's layer on a click, removes + one with −, and places new ones from its Add measurement menu. - **Annotations:** notes for developers on any layer, each with optional pinned properties that Dev Mode shows with their current values (and the style or variable behind them). `annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id, @@ -1428,8 +1531,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev - resources, code connections and comments still work); choosing a drawing tool in the toolbar - goes back to the design. Commands sent over MCP or the control channel aren't affected. + resources, measurements, code connections and comments still work); choosing a drawing tool in + the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected. - **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the file keeps a copy of it and everything in it as it was just before that edit (saved in the `.figcraft` file only while there is a change; resolving the change, marking it again, another @@ -1536,6 +1639,14 @@ Pass the returned target list to: - `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`, this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy resolved paints and keep their typography, without a paint-style link. +- `selection.bindColorVariable {targets, variable}` to bind a document color variable (id or name) + to those solid fill and stroke slots, as `variable.bind` does with `fills//color` or + `strokes//color`. A color style on the list and an earlier binding of the slot are removed. + Text ranges with fills of their own take the variable's value in the layer's modes, without a + binding. Gradients and other variable types are refused; scopes only filter what the Selection + colors menu offers, as for `variable.bind`. +- `selection.detachColor {targets}` to unlink the color style and the color variable of those + slots, keeping their values. Keep the targets fixed throughout a live interaction. A color becoming equal to another color must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits @@ -1730,25 +1841,47 @@ Align and distribute as one undo step: ### Step references -A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's -params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n` -(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that -result. This builds a nested tree in one call and one undo step, without guessing ids: +A step in a `batch` can use what an earlier step returned. Give the step a name with `"as"` (next to +`command` and `params`), and `{"$ref": "name"}` anywhere in a later step's params (a value, an item +in `ids`, a nested field) is replaced by the `id` in that step's result before the later step runs. +This builds a nested tree in one call and one undo step, without guessing ids or counting steps: ```json {"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[ - {"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}}, - {"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}}, - {"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}}, - {"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}} + {"command":"node.create","as":"card","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}}, + {"command":"node.create","params":{"type":"text","parent":{"$ref":"card"},"text":"Ada Lovelace"}}, + {"command":"node.create","as":"button","params":{"type":"frame","parent":{"$ref":"card"},"name":"Button"}}, + {"command":"node.create","params":{"type":"text","parent":{"$ref":"button"},"text":"Follow"}}]}}} ``` -A reference to the same or a later step, a negative or non-integer index, a missing `field` or any -other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are -`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The -`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable -value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, -so the references in its steps are to its own steps, not the outer batch's. +`{"$step": n}` refers to a step by its zero-based position instead, and works in the same places; +the two can be mixed. Names are any non-empty string and must be unique within the batch. + +`"field"` takes something other than the `id`: a key of the result (`{"$ref": "card", "field": +"name"}`) or a path into it, with `/` between the parts and list items by zero-based index, the way +`variable.bind` names `fills/0/color`. `edit.duplicate` returns `{"ids": [...]}`, so its first copy is +`ids/0`: + +```json +{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Copy","commands":[ + {"command":"edit.duplicate","as":"copy","params":{"ids":[5]}}, + {"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}} +``` + +A path may start with `/`, as in JSON Pointer (`/ids/0`). A key that itself holds `/` is matched as +written before it is read as a path. A path ends at whatever value it reaches, so `"field": "ids"` is +the whole list. + +These fail the whole batch, which is rolled back, with an error naming the step: two steps with the +same `as` (before any step runs); a `$ref` to a name no step has (the error lists the names there +are) or to the same or a later step; a `$step` to the same or a later step, or that is negative or +not an integer; a `field` the result doesn't have (the error names the full path and, for a list, +how many items it has); any key next to `$ref` or `$step` other than `field`. Only an object holding +a `$ref` or `$step` key is a reference: text such as `"$ref card"` is left as written. Both keys are +reserved in step params: an object holding either anywhere (in plugin data or a variable value too) +is read as a reference. A step that is itself a `command.batch` is passed on as written, so the +references in its steps are to its own steps and names, not the outer batch's; the outer batch can +reach into its result list with a path (`{"$ref": "inner", "field": "1/id"}`). The end-to-end tests in `crates/mcp/tests/it/agent_tasks.rs` are worked examples of realistic tasks (auto layout, a card built in one batch with step references, components and overrides, gradients diff --git a/docs/plugins.md b/docs/plugins.md index 9b1a8f4e..a473aa0e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -390,7 +390,10 @@ image sizes) and refuses it if anything is off. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and builds them, and leaves each example's manifest and module side by side in -`target/plugins/dist//`, ready to import. +`target/plugins/dist//`, ready to import. With `CARGO_TARGET_DIR` set, that location is +`$CARGO_TARGET_DIR/plugins/dist//`. Plugin compilation and host tests use the caller's +same Cargo target directory; wasm modules are built under its `wasm32-unknown-unknown/release/` +directory. The plugin workspace keeps its own lockfile and release profile. A plugin of your own: diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 2ded779d..a7b943e7 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** patch (top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -15,7 +15,7 @@ larger real prototypes. Remaining: 2–5 h. | Group | Figma | FigCraft | Missing | |---|---|---|---| -| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers, delays on mouse enter/leave/down/up; controllers on macOS, Linux and FreeBSD | +| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers; controllers on macOS, Linux and FreeBSD | | Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions | | Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — | | Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — | @@ -93,6 +93,7 @@ case pins the current behaviour). | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | +| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) | | Opening a link in place | missing | | | Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet | | Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) | @@ -103,6 +104,8 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present_delay_tests.rs`). Numbers unchanged | +| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged | | 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged | | 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. | | 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. | diff --git a/docs/releasing.md b/docs/releasing.md index 07492f6c..3dbc0fbf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,11 +101,14 @@ builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed it rather than ship without them. The browser has no system fonts, so a web build without craft-fonts can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin -must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no -Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an -installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, -Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` +must combine the Latin presentation catalog and the current core UI faces. Required web builds +validate the emitted Inter Regular/SemiBold and Noto Sans Georgian Regular faces after filtering; +a missing required face, style or asset fails the required web build. Excluded Adobe rows +are skipped before resolving their asset paths. +Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4 +are filtered on every target under FigCraft's asset policy. The web build keeps its smaller +allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each +embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS diff --git a/docs/server.md b/docs/server.md index 87e68b3d..a4c79ee3 100644 --- a/docs/server.md +++ b/docs/server.md @@ -336,8 +336,8 @@ the address it came from and when it was last opened (for expiry). beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced before the server tells anyone about the change. - **Compaction** writes the file's state as the next snapshot and starts an empty log, after - `compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server - stops. A log left over from a compaction cut short is replayed over the snapshot, which already + `compact_after` entries or `compact_mb` of log, when the last person leaves a file that was + edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already holds it all, so nothing changes. If a damaged generation field made a current log look like such a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported. - **Damage is skipped and reported, never deleted.** At startup a folder whose description, links diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7d3402e..88a92c7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -96,10 +96,10 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| -| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | +| 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on vectors, corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons and stars, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | @@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | @@ -423,7 +423,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede presenter) and variables (the local variables JSON passed along, next to the file or embedded: collections, modes, aliases, scopes, layer bindings and explicit modes). Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, - JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for + JPG, WebP, SVG and PDF, suffix, advanced options: ignore overlapping layers, include bounding box for text layers, and for SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard content wins over our own copy; pasted SVG lands centred in the view), and layers copied from @@ -641,6 +641,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | diff --git a/docs/translating.md b/docs/translating.md index 9dfca60c..0a8fcb62 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,12 +17,12 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian -(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a -maintainer changes its status. Russian (`ru`, Русский) was -reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar -tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, -and Swedish (`sv`) translates every UI string, command label and menu wording. +(`fi`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Polish (`pl`, +Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI +assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. +Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and +Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and +reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. ## The catalogs diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..74b056d8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -20,7 +20,7 @@ interaction feel not yet compared. | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | -| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | +| Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | @@ -70,7 +70,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -88,7 +88,7 @@ interaction feel not yet compared. | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | -| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | +| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | @@ -172,6 +172,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | +| 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | +| 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | | 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged | diff --git a/packaging/linux/flatpak/ai.storyteller.figcraft.yml b/packaging/linux/flatpak/ai.storyteller.figcraft.yml index 03cb5fba..542f1ab7 100644 --- a/packaging/linux/flatpak/ai.storyteller.figcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.figcraft.yml @@ -53,6 +53,7 @@ modules: build-commands: - cargo --offline build --release --locked -p figcraft - install -Dm755 target/release/figcraft /app/bin/figcraft + - install -Dm644 NOTICE /app/share/doc/figcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.figcraft.desktop /app/share/applications/ai.storyteller.figcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.figcraft.mime.xml /app/share/mime/packages/ai.storyteller.figcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index c6c1284e..21b2cb97 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -130,7 +130,6 @@ if has appimage; then fi APPDIR="$WORK/FigCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" # AppRun is a script, not a symlink: it installs the .desktop entry and icons into # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's # app_id to our icon instead of the generic Wayland logo (opt-out env in the diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 77611537..978874d3 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -17,12 +17,15 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then exit 0 fi -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP_BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP="$(mktemp -d "${TMP_BASE}/figcraft-signing.XXXXXX")" +# Remove only the decoded certificate: the keychain lives in the same directory and codesign +# needs it until the workflow's "Remove temporary keychain" step deletes it. +trap 'rm -f "${CERT:-}"' EXIT KEYCHAIN="$TMP/figcraft-signing.keychain-db" CERT="$TMP/figcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 7407af7b..5da527d1 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -115,6 +115,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/figcraft" "$APP/Contents/MacOS/FigCraft" cp "$WORK/FigCraft.icns" "$APP/Contents/Resources/FigCraft.icns" +cp "$ROOT/NOTICE" "$APP/Contents/Resources/" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). if [[ -n "${CRAFT_FONTS_DIR:-}" ]]; then mkdir -p "$APP/Contents/Resources/Licenses" diff --git a/packaging/windows/figcraft.wxs b/packaging/windows/figcraft.wxs index a0e40b24..dd89b869 100644 --- a/packaging/windows/figcraft.wxs +++ b/packaging/windows/figcraft.wxs @@ -18,6 +18,9 @@ --> + + + + + + @@ -82,8 +88,14 @@ + + + + + + bool { for png in &pngs { ico.extend_from_slice(png); } - ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok(); + let ico_path = dir.join("figcraft.ico"); + let _ = std::fs::remove_file(&ico_path); + ok &= std::fs::write(&ico_path, ico).is_ok(); // The lighter trace keeps the scalable theme icon cheap to render. let sd = dir.join("hicolor/scalable/apps"); let _ = std::fs::create_dir_all(&sd); - ok &= std::fs::copy(dir.join("figcraft-small.svg"), sd.join("ai.storyteller.figcraft.svg")).is_ok(); + let target = sd.join("ai.storyteller.figcraft.svg"); + let _ = std::fs::remove_file(&target); + ok &= std::fs::copy(dir.join("figcraft-small.svg"), &target).is_ok(); eprintln!("icon: wrote {}", dir.display()); ok } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 313de3c6..d0332ff5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -115,23 +115,24 @@ const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"]; fn plugins() -> bool { let root = root(); let manifest = root.join("plugins").join("Cargo.toml"); - let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("plugins"); + let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")); let ws = |args: &[&str]| { let mut c = cargo(); - c.args(args).arg("--manifest-path").arg(&manifest); + // Keep the caller's target; Cargo separates host and wasm artifacts itself. + c.args(args).arg("--manifest-path").arg(&manifest).env("CARGO_TARGET_DIR", &target); c }; let wasm = ["--target", "wasm32-unknown-unknown", "--locked"]; let ok = run(&mut ws(&["fmt", "--all", "--check"])) - && run(ws(&["clippy", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target).args(["--", "-D", "warnings"])) - && run(ws(&["test", "--workspace", "--locked"]).arg("--target-dir").arg(&target)) - && run(ws(&["build", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target)); + && run(ws(&["clippy", "--workspace", "--release"]).args(wasm).args(["--", "-D", "warnings"])) + && run(&mut ws(&["test", "--workspace", "--locked"])) + && run(ws(&["build", "--workspace", "--release"]).args(wasm)); if !ok { return false; } for name in EXAMPLE_PLUGINS { let module = format!("{}.wasm", name.replace('-', "_")); - let dist = target.join("dist").join(name); + let dist = target.join("plugins/dist").join(name); let copied = std::fs::create_dir_all(&dist) .and_then(|()| std::fs::copy(root.join("plugins/examples").join(name).join("figcraft-plugin.json"), dist.join("figcraft-plugin.json"))) .and_then(|_| std::fs::copy(target.join("wasm32-unknown-unknown/release").join(&module), dist.join(&module))); @@ -201,10 +202,11 @@ fn assets() -> bool { fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { + let Ok(ft) = e.file_type() else { continue }; let p = e.path(); - if p.is_dir() { + if ft.is_dir() && !ft.is_symlink() { walk(&p, f); - } else { + } else if !ft.is_dir() { f(&p); } }