diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..2408084c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9700a8e9..3dfa31d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,12 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt # Only main saves caches; PRs restore main's, so PR caches don't evict main's. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true @@ -75,10 +75,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 save-if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..ecee45a6 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 86bccfa3..459c8320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -258,7 +258,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -392,7 +392,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -427,7 +427,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -491,15 +491,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -510,10 +501,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -521,6 +515,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -582,6 +586,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -605,7 +633,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -672,9 +700,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -766,9 +794,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -829,6 +857,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -861,11 +895,20 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -951,9 +994,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -961,21 +1004,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -988,7 +1031,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1038,7 +1081,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1313,7 +1356,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1335,9 +1378,9 @@ checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1413,9 +1456,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.7.0" +version = "0.9.0" dependencies = [ "arboard", + "craft-window", "eframe", "egui", "env_logger", @@ -1427,11 +1471,9 @@ dependencies = [ "fmv-macos-events", "image", "log", - "raw-window-handle", "rfd", "serde_json", "wat", - "wayland-client", "webbrowser", "windows", "winit", @@ -1440,7 +1482,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1454,7 +1496,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1469,7 +1511,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1492,7 +1534,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.7.0" +version = "0.9.0" dependencies = [ "kurbo", "serde", @@ -1501,7 +1543,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1513,7 +1555,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1541,7 +1583,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1556,7 +1598,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.7.0" +version = "0.9.0" dependencies = [ "kurbo", "serde", @@ -1564,7 +1606,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1572,7 +1614,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1588,7 +1630,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1597,7 +1639,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1614,7 +1656,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-color", "serde", @@ -1627,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.7.0" +version = "0.9.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1645,7 +1687,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.7.0" +version = "0.9.0" dependencies = [ "crc32fast", "ctrlc", @@ -1669,7 +1711,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1685,7 +1727,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1699,7 +1741,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.7.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "egui", @@ -1724,7 +1766,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.7.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -1740,9 +1782,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1810,9 +1852,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1857,7 +1899,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1908,7 +1950,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2606,9 +2648,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2635,7 +2677,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2677,9 +2719,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -3236,7 +3278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3492,15 +3534,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -3533,16 +3566,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3598,16 +3632,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -3619,24 +3643,11 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] [[package]] name = "rand_core" @@ -3646,11 +3657,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -3723,7 +3734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3734,7 +3745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3745,7 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3769,9 +3780,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3916,9 +3927,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -3940,9 +3951,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4077,14 +4088,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4101,7 +4112,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4235,9 +4246,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -4401,9 +4412,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4418,7 +4429,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4480,7 +4491,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4566,9 +4577,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "pin-project-lite", ] @@ -4687,7 +4698,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.3", + "rand", "sha1", "thiserror 2.0.21", ] @@ -4750,9 +4761,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-properties" @@ -4844,9 +4855,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "js-sys", "serde_core", @@ -4963,7 +4974,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4978,12 +4989,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -5038,9 +5049,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -5049,9 +5060,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -5062,9 +5073,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -5242,9 +5253,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -5575,7 +5586,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5584,16 +5595,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5611,31 +5613,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5653,96 +5638,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5806,9 +5743,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -5934,7 +5871,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.9.0" dependencies = [ "figcraft-engine", "serde_json", @@ -5959,7 +5896,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6031,7 +5968,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -6039,9 +5976,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -6071,18 +6008,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6106,7 +6043,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6146,23 +6083,21 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.21", "zopfli", ] @@ -6180,9 +6115,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -6207,9 +6142,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -6222,14 +6157,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6242,6 +6177,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 932da46f..6cc17546 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.7.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -34,6 +34,9 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the +# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/README.md b/README.md index b858b5c0..a06a96be 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..f43cd015 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index fac4842b..6196d08d 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -16,6 +16,10 @@ figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-format = { workspace = true } figcraft-text = { workspace = true } +# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the +# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged +# 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"] } serde_json = { workspace = true } @@ -45,7 +49,6 @@ wat = { workspace = true } winresource = "0.1" [target.'cfg(target_os = "linux")'.dependencies] -# Use vector window controls without a title-font renderer or embedded Cantarell. +# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor +# probe that used to need raw-window-handle and wayland-client here now lives in craft-window. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } -raw-window-handle = "0.6" -wayland-client = "0.31" diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs deleted file mode 100644 index f47b61e0..00000000 --- a/apps/figcraft/src/linux_window.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Request winit's frame only on Wayland compositors without xdg-decoration. -//! Desktop/session names cannot identify the backend of an individual window. - -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::mpsc; -use std::time::Duration; - -use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; -use wayland_client::globals::{GlobalListContents, registry_queue_init}; -use wayland_client::protocol::wl_registry; -use wayland_client::{Connection, Dispatch, QueueHandle}; - -/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET. -/// Never consume, duplicate or query that inherited connection: there may be no -/// independently addressable display, or WAYLAND_DISPLAY may name another server. -pub fn display_socket() -> Option { - if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) { - return None; - } - let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?; - let path = PathBuf::from(display); - if path.is_absolute() { - Some(path) - } else { - std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path)) - } -} - -pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool { - let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_))); - if !wayland { - log::debug!("window controls: non-Wayland window, retaining custom controls"); - return false; - } - let server_decorations = socket.and_then(probe); - log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}"); - native_frame(wayland, server_decorations) -} - -fn native_frame(wayland: bool, server_decorations: Option) -> bool { - wayland && server_decorations == Some(false) -} - -fn probe(socket: PathBuf) -> Option { - // The compositor must not be able to block startup on a registry roundtrip. - // On failure/timeout retain FigCraft's own working title bar and buttons. - let (send, receive) = mpsc::sync_channel(1); - std::thread::Builder::new() - .name("wayland-decorations".into()) - .spawn(move || { - let _ = send.send(registry_has_decorations(socket)); - }) - .ok()?; - receive.recv_timeout(Duration::from_millis(500)).ok().flatten() -} - -fn registry_has_decorations(socket: PathBuf) -> Option { - // A fresh connection leaves winit's event queue and inherited descriptors alone. - let stream = UnixStream::connect(socket).ok()?; - let connection = Connection::from_socket(stream).ok()?; - let (globals, _queue) = registry_queue_init::(&connection).ok()?; - Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"))) -} - -struct Registry; - -impl Dispatch for Registry { - fn event( - _state: &mut Self, - _registry: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _queue: &QueueHandle, - ) { - } -} - -#[cfg(test)] -mod tests { - #[test] - fn only_wayland_without_server_decorations_uses_the_frame() { - for (wayland, server_decorations, expected) in [ - (false, None, false), - (false, Some(false), false), - (false, Some(true), false), - (true, Some(true), false), - (true, Some(false), true), - (true, None, false), - ] { - assert_eq!(super::native_frame(wayland, server_decorations), expected); - } - } - - #[test] - fn an_unavailable_display_keeps_the_custom_controls() { - assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None); - } - - #[test] - fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> { - let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id())); - let listener = std::os::unix::net::UnixListener::bind(&socket)?; - let start = std::time::Instant::now(); - assert_eq!(super::probe(socket.clone()), None); - assert!(start.elapsed() < std::time::Duration::from_secs(2)); - drop(listener); - let _ = std::fs::remove_file(socket); - Ok(()) - } -} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 00856c5d..0b71149a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -21,8 +21,6 @@ mod args; mod control_server; mod gamepad; mod graphics; -#[cfg(target_os = "linux")] -mod linux_window; mod recovery; mod services; mod startup; @@ -222,8 +220,13 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); - // Start frameless on Linux; the app creator checks the actual display backend - // before enabling a client-side frame. Other windows inherit that decision. + // Who draws the window frame is decided once, before the viewport is built, by the shared + // craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is + // kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the + // decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway, + // Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter — + // which draws no server-side frame — leaves it to the app's own custom title bar. This replaces + // the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE. fig.ui.title_bar = desktop_title_bar(); viewport = window_chrome(viewport, fig.ui.title_bar); if let Some(icon) = icon { @@ -232,8 +235,6 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - #[cfg(target_os = "linux")] - let linux_display = linux_window::display_socket(); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] @@ -244,11 +245,6 @@ fn main() -> eframe::Result { "FigCraft", options, Box::new(move |_cc| { - #[cfg(target_os = "linux")] - if linux_window::needs_native_frame(_cc, linux_display) { - let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native); - _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true)); - } Ok(Box::new(App { queue, ctx_slot, @@ -274,9 +270,35 @@ fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { !first_run && (!start_on_home || control) } +/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app +/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR` +/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the +/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager +/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one). fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { + let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT")); + title_bar_for(choice.into_frame()) +} + +/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`]. +/// +/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop` +/// means the desktop draws it, but FigCraft keeps each platform's existing look there: +/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged. +/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used +/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that. +/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only +/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame. +fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; - if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } + match frame { + craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset, + // Windows kept FigCraft's own title bar before this change; keep it. + craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom, + craft_window::Frame::Desktop => TitleBar::Native, + // The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override). + craft_window::Frame::App(_) => TitleBar::Custom, + } } fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder { @@ -288,15 +310,55 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T } } -#[cfg(all(test, target_os = "linux"))] -mod linux_window_tests { +#[cfg(test)] +mod title_bar_tests { + use figcraft_ui_egui::TitleBar; + + /// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override) + /// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the + /// decorations so FigCraft draws its own. #[test] - fn linux_starts_with_the_existing_frameless_title_bar() { - let bar = super::desktop_title_bar(); - assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom); + fn the_app_frame_maps_to_the_custom_title_bar() { + let layout = craft_window::Layout::default(); + let bar = super::title_bar_for(craft_window::Frame::App(layout)); + assert_eq!(bar, TitleBar::Custom); let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); assert_eq!(viewport.decorations, Some(false)); } + + /// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic + /// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's + /// native frame. The Linux case is the only behaviour change: KDE and the other + /// server-side-decoration compositors now keep their native frame. + #[test] + fn the_desktop_frame_is_mapped_per_platform() { + let bar = super::title_bar_for(craft_window::Frame::Desktop); + if cfg!(target_os = "macos") { + assert_eq!(bar, TitleBar::Inset); + } else if cfg!(target_os = "windows") { + // Windows kept its own title bar before and must keep it now. + assert_eq!(bar, TitleBar::Custom); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(false)); + } else { + assert_eq!(bar, TitleBar::Native); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(true)); + } + } + + /// macOS is untouched by this change: its inset title bar with the system traffic lights stays, + /// and `window_chrome` still asks for the full-size content view rather than toggling the + /// window decorations. + #[test] + #[cfg(target_os = "macos")] + fn macos_keeps_its_inset_title_bar() { + let bar = super::desktop_title_bar(); + assert_eq!(bar, TitleBar::Inset); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.fullsize_content_view, Some(true)); + assert_eq!(viewport.title_shown, Some(false)); + } } #[cfg(test)] diff --git a/contributors/people.toml b/contributors/people.toml index b9172891..27307935 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -24,6 +24,10 @@ display_name = "VanTan" real_name = "Tyler Jennings" display_name = "tylerjenningsw" +[people.SiadeLucas] +real_name = "Siade Lucas" +sync_github_name = true + [people.Silvertree2010] display_name = "Netter" sync_github_name = false diff --git a/crates/collab/src/code.rs b/crates/collab/src/code.rs index c046682a..6eebe9f7 100644 --- a/crates/collab/src/code.rs +++ b/crates/collab/src/code.rs @@ -135,7 +135,7 @@ impl Local { let query = link.strip_prefix("figcraft://join?")?; let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)); let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?; - let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?; + let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? }; (get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local) } diff --git a/crates/collab/src/code_tests.rs b/crates/collab/src/code_tests.rs index 2dd902b3..31bd3488 100644 --- a/crates/collab/src/code_tests.rs +++ b/crates/collab/src/code_tests.rs @@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() { "figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az", + "figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001", "figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a", "ws://192.168.1.20:47900/?file=local&token=local-0123456789a", "", diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 8e9f98d9..fdad0fa7 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -162,7 +162,13 @@ impl Color { p.parse::().ok().map(|v| v / 255.0) } }; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); } if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { @@ -170,7 +176,13 @@ impl Color { let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } if t == "transparent" { diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 4cf875bb..2216b6fa 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -114,3 +114,13 @@ fn infinite_channels_clamp() { let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } + +#[test] +fn malformed_alpha_or_extra_args_rejected() { + assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); + assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None); + assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0); + assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 097dd189..458426ad 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -60,6 +60,9 @@ impl LetterSpacing { } pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } let r = (v * 100.0).round() / 100.0; if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } } @@ -760,6 +763,15 @@ impl TextData { mod case_tests { use super::*; + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + #[test] fn title_case_respects_context() { assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index bf99c592..6a5b1012 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -56,11 +56,17 @@ pub fn specs() -> Vec { s.set_selection(out.iter().filter_map(id_of).collect()); Ok(json!({"ids": out})) }), - spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine), + spec!( + "component.combineVariants", + "Combine as variants", + "{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")", + true, + combine + ), spec!( "component.addVariant", "Add variant", - "{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", + "{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", true, add_variant ), @@ -330,23 +336,25 @@ fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": target})) } +/// The name a new component set takes from its first variant: the path before the last '/' +/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name +/// ("State=Default, Size=Small") names no set. +fn set_name_from(variant: &str) -> Option { + let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); + (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) +} + /// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { - let cmd = "component.combineVariants"; +fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into())); + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); } let bounds = doc.bounds_of(ids).unwrap_or_default(); let pw = doc.world_transform(parent); let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids - .first() - .and_then(|i| doc.node(*i)) - .map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string()) - .filter(|n| !n.is_empty() && !n.contains(',')) - .unwrap_or_else(|| "Component".into()); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); let set = doc.alloc_id(); let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); @@ -358,22 +366,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { Ok(set) } -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - return Err(bad(cmd, "select components only")); + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None } - let ids = z_sorted(doc, &ids); - let set = wrap_in_set(doc, &ids)?; +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; for (k, i) in ids.iter().enumerate() { - // Name variants "Property 1=Variant N" unless already in prop=value form. let node = doc.get_mut(*i)?; if !node.name.contains('=') { node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); } } + Ok(set) +} + +fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; s.set_selection(vec![set]); Ok(json!({"id": set})) } @@ -397,15 +454,19 @@ fn fit_set(doc: &mut Document, set: NodeId) { fn add_variant(s: &mut Session, p: &Value) -> Result { let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; let doc = s.doc_mut()?; let (set, base) = match doc.get(t)?.kind { NodeKind::ComponentSet { .. } => { let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; (t, last) } - NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t), - _ => return Err(bad(cmd, "select a component set or variant")), + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), }; let options = variant_options(doc, set); let mut pairs = variant_props(&doc.get(base)?.name); @@ -1103,7 +1164,7 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { .unwrap_or_else(|| "Default".into()); let set = match owner { Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(doc, &[c])?, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, }; for c in Owner::Set(set).components(doc) { let cn = doc.get_mut(c)?; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8e59b303..7380711f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -542,7 +542,8 @@ fn load_with( return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - if loaded.doc.name.is_empty() { + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { loaded.doc.name = file_stem(name_hint); } Ok((loaded, false)) @@ -853,8 +854,14 @@ fn save_to(s: &mut Session, path: String) -> Result { } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - let bytes = file_bytes(ds)?; + 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); + } + let bytes = snapshot.encode()?; write(&path, &bytes)?; + ds.doc = snapshot.doc; ds.path = Some(path.clone()); ds.mark_saved(); ds.revision += 1; diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index ff78f73b..7a5c1f90 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -879,3 +879,35 @@ fn auto_layout_instances_follow_their_main() { assert_eq!(node(&s, i).child_layout.min_w, Some(80.0)); healthy(&s); } + +#[test] +fn a_new_component_set_is_named_after_its_first_variant() { + // "Button/State=Hover" groups under "Button": the set takes the path before the last '/'. + // A bare property name ("State=Default, Size=Small") names no set, and neither does an empty + // name: the set gets the next numbered default, like a new component. A plain name is kept. + let rows = [ + ("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"), + ("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"), + ("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""), + ("", "Component 1", "empty name: the numbered default"), + ]; + for (name, expected, why) in rows { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}})); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name"); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]}))); + assert_eq!(node(&s, set).name, expected, "{why}"); + healthy(&s); + } + + // Add variant on a lone component (a variant property on it) wraps it in a set named after it. + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"}))); + assert_eq!(node(&s, set).name, "Button"); + assert_eq!(node(&s, c).name, "State=Default"); + healthy(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 82781448..e0b2f12c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1062,7 +1062,9 @@ impl Session { }; let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; let changed = !Arc::ptr_eq(&ds.doc, &bdoc); - if changed { + // Saving may adopt the chosen filename as the document title. The encoded snapshot is + // already final; normalising it again would detach it from the saved baseline. + if changed && !matches!(spec.id, "file.save" | "file.saveAs") { normalize(ds.doc_mut(), &bdoc); } let edit = changed && ds.interaction.is_none(); @@ -1881,6 +1883,8 @@ mod variables_import_shapes_tests; #[cfg(test)] mod variables_io_tests; #[cfg(test)] +mod variants_discovery_tests; +#[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index 7f5a4488..c2c75bb5 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("file.new", &json!({})).unwrap(); let offline_path = saved_file(&mut s); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live_of(uid).doc_uid, Some(uid)); @@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("doc.switch", &json!({"index": 0})).unwrap(); s.execute("collab.leave", &json!({})).unwrap(); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live().doc_uid, None); diff --git a/crates/engine/src/online.rs b/crates/engine/src/online.rs index b982580e..66713732 100644 --- a/crates/engine/src/online.rs +++ b/crates/engine/src/online.rs @@ -120,7 +120,12 @@ pub fn same_server(a: &str, b: &str) -> bool { let s = plain_server(s).to_ascii_lowercase(); let s = s.trim_end_matches('/'); let port = if s.starts_with("wss://") { ":443" } else { ":80" }; - s.strip_suffix(port).unwrap_or(s).to_string() + let start = s.find("://").map_or(0, |i| i + 3); + let end = s[start..].find('/').map_or(s.len(), |i| start + i); + match s[start..end].strip_suffix(port) { + Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]), + None => s.to_string(), + } }; norm(a) == norm(b) } diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs index 1e341b14..c211c505 100644 --- a/crates/engine/src/online_tests.rs +++ b/crates/engine/src/online_tests.rs @@ -583,3 +583,11 @@ fn debug_output_of_a_record_leaves_the_secrets_out() { no_secrets("debug", &text); assert!(text.contains("share.example") && text.contains(".."), "{text}"); } + +#[test] +fn same_server_ignores_the_default_port_before_a_path() { + use crate::online::same_server; + assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/")); + assert!(same_server("ws://host:80/x", "ws://host/x")); + assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/")); +} diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index 4473ef7c..d635808e 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -89,6 +89,46 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String] assert_eq!(s.prefs.recent_files, recent); } +#[test] +fn first_native_save_uses_chosen_filename_for_an_untitled_document() { + let mut files = SaveFiles::new(); + let path = files.path("Redis.figcraft"); + let copy = files.path("Redis copy.figcraft"); + let manual_copy = files.path("Other.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis"); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + + s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis copy"); + s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Chosen title"); +} + +#[test] +fn opening_an_older_file_uses_its_filename_only_for_the_default_title() { + let mut files = SaveFiles::new(); + let old_path = files.path("Redis.figcraft"); + let named_path = files.path("Other.figcraft"); + let mut original = Session::new(); + std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title"); +} + fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { let bytes = std::fs::read(path).unwrap(); assert_eq!(result["format"], "fig"); diff --git a/crates/engine/src/variants_discovery_tests.rs b/crates/engine/src/variants_discovery_tests.rs new file mode 100644 index 00000000..b792e727 --- /dev/null +++ b/crates/engine/src/variants_discovery_tests.rs @@ -0,0 +1,179 @@ +//! Combine as variants and Add variant where people look for them (#903): the predicates the +//! context menu and the Design panel offer them by, combining refusing components that are +//! variants already (it used to put a set inside a set), and Add variant on a main component that +//! isn't in a set yet making it one. + +use serde_json::json; + +use crate::Session; +use crate::cmd::component::{can_add_variant, can_combine_variants}; +use crate::components::tests::{ex, healthy, idv, kids, node}; +use figcraft_doc::*; + +/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}})); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +fn sets(s: &Session) -> usize { + s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count() +} + +#[test] +fn combine_is_offered_for_two_or_more_main_components_only() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_combine_variants(doc, &[a, b])); + assert!(!can_combine_variants(doc, &[]), "nothing selected"); + assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)"); + assert!(!can_combine_variants(doc, &[a, a]), "the same component twice"); + assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component"); + assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component"); + + let c = component(&mut s, "C", 400.0); + let d = component(&mut s, "D", 600.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]}))); + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[c, d]), "variants of one set"); + assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component"); + assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component"); + + // Components in different frames still combine. + let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100}))); + let e = component(&mut s, "E", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder})); + assert!(can_combine_variants(s.doc().unwrap(), &[a, e])); +} + +#[test] +fn add_variant_is_offered_for_sets_variants_and_lone_components() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let lone = component(&mut s, "Lone", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_add_variant(doc, set)); + assert!(can_add_variant(doc, a)); + assert!(can_add_variant(doc, lone)); + assert!(!can_add_variant(doc, frame)); + assert!(!can_add_variant(doc, inst)); + assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance"); + assert!(!can_add_variant(doc, NodeId(999_999))); +} + +#[test] +fn combining_variants_again_is_refused_instead_of_nesting_sets() { + let mut s = Session::new(); + let a = component(&mut s, "State=Default", 0.0); + let b = component(&mut s, "State=Hover", 200.0); + let lone = component(&mut s, "State=Pressed", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + for ids in [vec![a, b], vec![a, lone]] { + let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err(); + assert!(err.to_string().contains("variants already"), "{err}"); + } + assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing"); + assert_eq!(undo_len(&s), steps); + assert_eq!(sets(&s), 1); + assert_eq!(kids(&s, set), vec![a, b]); + // One lone component still makes a set of one (other commands rely on it). + ex(&mut s, "component.combineVariants", json!({"ids": [lone]})); + assert_eq!(sets(&s), 2); + healthy(&s); +} + +#[test] +fn combining_refuses_nested_components_and_components_inside_instances() { + let mut s = Session::new(); + // A "Card" component holding an "Icon" component. + let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card}))); + let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]}))); + let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]}))); + let other = component(&mut s, "Other", 300.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300}))); + let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon"); + + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it"); + assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine"); + assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance"); + let before = s.doc().unwrap().clone(); + let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err(); + assert!(err.to_string().contains("inside it"), "{err}"); + assert_eq!(*s.doc().unwrap(), before); +} + +#[test] +fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() { + let mut s = Session::new(); + let c = component(&mut s, "Button", 0.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c}))); + let set = node(&s, c).parent.unwrap(); + let set_node = node(&s, set); + assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. })); + assert_eq!(set_node.name, "Button", "the set takes the component's name"); + assert_eq!(kids(&s, set), vec![c, v]); + assert_eq!(node(&s, c).name, "Property 1=Default"); + assert_eq!(node(&s, v).name, "Property 1=Variant2"); + assert_eq!(s.selection(), vec![v]); + assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height); + assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main"); + assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step"); + healthy(&s); + + s.undo().unwrap(); + assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back"); +} + +#[test] +fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() { + let mut s = Session::new(); + let c = component(&mut s, "Size=Small", 0.0); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err()); + assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind"); + assert_eq!(undo_len(&s), steps); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"}))); + assert_eq!(node(&s, c).name, "Size=Small"); + assert_eq!(node(&s, v).name, "Size=Large"); + healthy(&s); +} + +#[test] +fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() { + let mut s = Session::new(); + let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"}))); + ex(&mut s, "layout.add", json!({"ids": [row]})); + let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + let c = component(&mut s, "Chip", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row})); + let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + + ex(&mut s, "component.addVariant", json!({"id": c})); + let set = node(&s, c).parent.unwrap(); + assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row"); + assert_eq!(kids(&s, set).len(), 2); + healthy(&s); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index da351e5b..e0a4dde0 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -284,7 +284,9 @@ impl ReverseSubpaths for BezPath { kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), } } - out.close_path(); + if matches!(seg_path.elements().last(), Some(kurbo::PathEl::ClosePath)) { + out.close_path(); + } } out } diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index b99d7d8c..15e24d49 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -108,3 +108,18 @@ fn set_rotation_keeps_center() { } } } + +#[test] +fn reverse_subpaths_preserves_open_and_closed() { + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 0.0)); + open.line_to((10.0, 10.0)); + let r = ::reverse_subpaths(&open); + assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements()); + + let mut closed = open.clone(); + closed.close_path(); + let r = ::reverse_subpaths(&closed); + assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements()); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 1433b942..55176aaf 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -586,7 +586,8 @@ impl W<'_> { } let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len()); let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) { Some(fills) => { let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into()); @@ -601,7 +602,8 @@ impl W<'_> { continue; } let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y)); let _ = write!(spans, "{}", num(x), num(y), esc(&text)); } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 5a333d07..8dece77f 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -116,6 +116,25 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } +#[test] +fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { + use figcraft_doc::{StyleRun, TextCase, TextData}; + figcraft_text::scan_system_fonts(); + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("hello"); + text.style.font_size = 24.0; + text.style.case = TextCase::Title; + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 2, end: 3, style }); + text.normalize(); + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 200.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap(); + assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); +} + #[test] fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { use figcraft_doc::{StrokeJoin, VectorNetwork}; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49163986..46451ae0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import")); + // A browser names no dragged file until the drop, so documents there still say "import". + let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); + crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -504,10 +506,15 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) { return false; } - // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]). + // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]); + // a dropped document opens as a file of its own, as on the home screen. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { let path = f.path().to_path_buf(); + if crate::is_document(&path) { + crate::pending_open::open_dropped(app, &f); + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); let (params, read) = if path.exists() { diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index 696ff322..eea2278e 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -7,7 +7,7 @@ use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_engine::Session; use serde_json::{Value, json}; -use crate::comments::{cluster, insert_mention, mention_matches, mention_query}; +use crate::comments::{cluster, insert_mention, mention_matches, mention_query, start_draft}; use crate::present_tests::{Ui, id}; fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { @@ -41,6 +41,35 @@ fn comments(u: &Ui) -> Value { u.app.inspect()["comments"].clone() } +#[test] +fn switching_from_comment_to_move_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + u.app.run("tool.set", json!({"tool": "move"})); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + +#[test] +fn control_channel_tool_switch_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + let invalid = json!({"command": "tool.set", "params": {"tool": "not-a-tool"}}); + assert!(crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &invalid).is_err()); + assert!(comments(&u)["draft"].is_array()); + + crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &json!({"command": "tool.set", "params": {"tool": "move"}})) + .unwrap(); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 6c9ff7d4..2b2573c7 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -2,8 +2,9 @@ //! //! - An instance: its main component (with a swap menu), variant dropdowns, property values, //! the selected layer's overrides, and Reset / Detach / Go to main / Push changes. -//! - A component or component set: its properties (add, rename, default, delete) and, for a set, -//! the variant property matrix. +//! - A component or component set: its properties (add, rename, default, delete), Add variant +//! (a lone main component becomes a set) and, for a set, the variant property matrix. +//! - Several main components: Combine as variants. //! - A layer inside a component: bind its visibility / text / instance to a property, or make a //! frame a slot. //! - A slot inside an instance (and an instance's slot properties): fill it with components @@ -11,6 +12,7 @@ use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::*; +use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants}; use figcraft_engine::components::{ PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts, variant_options, variant_props, variant_set, @@ -27,7 +29,10 @@ use crate::widgets; /// Entry point (called from the Design panel header for every selection). pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let [id] = sel else { return }; + let [id] = sel else { + combine_ui(app, ui, doc, sel); + return; + }; let Some(n) = doc.node(*id) else { return }; match &n.kind { NodeKind::Instance { .. } => instance_ui(app, ui, doc, n), @@ -57,6 +62,19 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No } } +/// Two or more main components selected: a button combines them into a component set. +fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + if !can_combine_variants(doc, sel) { + return; + } + row(ui, 32.0, |ui| { + let label = crate::i18n::command("component.combineVariants", "Combine as variants"); + if widgets::dropdown(ui, &label, 160.0, Some(Icon::ComponentSet)).clicked() { + app.run("component.combineVariants", json!({"ids": sel})); + } + }); +} + /// A component's description: a multi-line field, saved when it loses focus. fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); @@ -365,7 +383,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }); } } - if is_set { + // A lone main component too: Add variant makes it a set of itself and the new variant. + if can_add_variant(doc, owner) { row(ui, 32.0, |ui| { if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() { app.run("component.addVariant", json!({"id": owner})); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4a8dd15..2d35609a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -239,6 +239,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index c174242a..b532d9a7 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -180,19 +180,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { - let path = f.path().to_path_buf(); - if !crate::opens(&path) { + if !crate::opens(f.path()) { app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); continue; } - // `opens` saw an extension, so there is a file name to tell the format by. - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let (params, read) = if path.exists() { - (json!({"path": path.to_string_lossy()}), None) - } else { - (json!({"name": name}), Some(crate::dropped_bytes(&f, &name))) - }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); + crate::pending_open::open_dropped(app, &f); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c74c35bf..ebbf6d95 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -668,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() { #[test] fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() { let dir = Temp::new(); - let path = dir.path("Canvas import.figcraft"); - let mut source = Session::new(); - source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let path = dir.path("Canvas import.svg"); + std::fs::write(&path, r#""#).unwrap(); let mut session = Session::new(); let target_uid = session.ds().unwrap().uid; @@ -696,7 +695,55 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc u.frame(vec![]); assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front"); - assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); + let layers = |uid: u64| { + let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc; + doc.node(doc.pages[0]).unwrap().children.len() + }; + assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it"); +} + +#[test] +fn dropping_a_document_on_canvas_opens_it_as_a_new_file() { + let dir = Temp::new(); + let path = dir.path("Dropped.figcraft"); + let mut saved = Session::new(); + saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let before = u.app.session.doc().unwrap().clone(); + let s = drop_file(&mut u, &path); + assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens"); + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Opening as a new file"), "the card says so too"); + u.frame(vec![]); + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front"); + assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added"); +} + +#[test] +fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() { + // Every drop in the browser (#916). + let dir = Temp::new(); + let saved = dir.path("Saved.figcraft"); + Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file"); + assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it"); } #[test] diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 6ae19546..192073b3 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -26,6 +26,10 @@ pub(crate) struct Harness { impl Harness { pub(crate) fn new(s: Session) -> Self { + // The one system font scan bumps the layout generation, which makes the canvas render + // again: scanned here, before the first frame, rather than by whichever test happens to + // run first while this one waits for idle (#333). + figcraft_text::scan_system_fonts(); Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index dfab115a..f172b309 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -229,6 +229,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); + let page_key = (uid, cur); + let scroll_to_cur = app.ui.pages_seen_page != Some(page_key); + let mut current_row_rendered = false; // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { @@ -238,6 +241,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if id == cur { + current_row_rendered = true; + if scroll_to_cur { + ui.scroll_to_rect(r, None); + } + } if !filtering { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } @@ -301,6 +310,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } reorder.finish(ui) }); + if current_row_rendered { + app.ui.pages_seen_page = Some(page_key); + } // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, // even one scrolled out of view. if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index b963ee82..4d987e66 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -507,3 +507,59 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { // A panel too short for both keeps the three rows. assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); } + +/// Selecting a page that is far down the list automatically scrolls it into view. +#[test] +fn selecting_a_distant_page_scrolls_it_into_view() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..40 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let first = pages[0]; + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let layer = u.row_at(rect); + u.frame(vec![]); + // Select the last page. + u.app.run("page.select", json!({"id": last})); + for _ in 0..15 { + u.frame(vec![]); + } + assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last))); + let opened = pages_down_to(&mut u, layer.y - 60.0); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); +} + +/// If the active page is filtered out by search, it is not marked as seen +/// until the filter is cleared and its row actually renders. +#[test] +fn filtered_out_active_page_is_seen_only_after_rendering() { + let mut s = Session::new(); + let p1 = s.doc().unwrap().pages[0]; + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap(); + s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap(); + s.execute("page.select", &json!({"id": p1})).unwrap(); + let uid = s.ds().unwrap().uid; + let mut u = Ui::new(s); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1))); + + // Filter to only "Alpha", then select "Beta" (p2). + u.app.ui.page_search = true; + u.app.ui.page_filter = "Alpha".into(); + u.app.run("page.select", json!({"id": p2})); + u.frame(vec![]); + + // Beta was filtered out and not rendered, so pages_seen_page has not recorded it. + assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2))); + + // Clear filter: Beta renders now and is recorded. + u.app.ui.page_filter.clear(); + u.frame(vec![]); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2))); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..c93333b3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -215,6 +215,8 @@ mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; #[cfg(test)] +mod variants_discovery_tests; +#[cfg(test)] mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; @@ -495,6 +497,8 @@ pub struct UiState { pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, + /// The document and active page last drawn in the pages list. + pub pages_seen_page: Option<(u64, NodeId)>, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -667,6 +671,7 @@ impl Default for UiState { page_search: false, page_filter: String::new(), pages_height: None, + pages_seen_page: None, expanded: BTreeSet::new(), rename: None, rename_page: None, @@ -831,9 +836,17 @@ impl FigApp { } } + /// Dismiss the composer after a successful switch away from the Comment tool. + pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) { + if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { + self.ui.comments.draft = None; + } + } + /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { let r = self.session.execute(id, ¶ms); + self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); r.map_err(|e| self.failed(id, &e)).ok() } @@ -1817,6 +1830,12 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } +/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. +pub(crate) fn is_document(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) +} + /// Reads the contents of dropped file `name` that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e81a7c49..9659c6b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -853,6 +853,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { None } }); + // Two or more main components combine into a component set. Judged on the selection + // the command acts on: a layer row right-clicked in a multi-selection passes only itself. + let combine = app + .session + .doc() + .ok() + .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) + .map(|_| i18n::command("component.combineVariants", "Combine as variants")); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -910,6 +918,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, entry); } + if let Some(label) = &combine + && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + { + entries.insert(at + 1, (label, "component.combineVariants")); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 501367c0..f5e978c6 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -32,6 +32,16 @@ pub struct ImportTarget { pub name: String, } +/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there +/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by. +pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) { + let path = f.path(); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let (params, read) = + if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) }; + app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read }); +} + fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { let target = match pending.target { Some(target) => target, diff --git a/crates/ui-egui/src/variants_discovery_tests.rs b/crates/ui-egui/src/variants_discovery_tests.rs new file mode 100644 index 00000000..e153d982 --- /dev/null +++ b/crates/ui-egui/src/variants_discovery_tests.rs @@ -0,0 +1,111 @@ +//! Combine as variants and Add variant where people look for them (#903), driven through egui +//! headless: the Design panel offers Combine for two or more main components and Add variant for +//! a lone one, and the canvas / layers context menu lists Combine only when it applies. + +use egui::Pos2; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +const COMBINE: &str = "Combine as variants"; + +/// A session with lone main components named A, B and C side by side, and a frame. Returns +/// (session, [a, b, c], frame). +fn doc() -> (Session, [NodeId; 3], NodeId) { + let mut s = Session::new(); + let mut comps = Vec::new(); + for (k, name) in ["A", "B", "C"].iter().enumerate() { + let f = id(&s.execute("frame.create", &json!({"x": k * 160, "y": 0, "width": 120, "height": 40, "name": name})).unwrap()); + comps.push(id(&s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 200, "width": 60, "height": 60})).unwrap()); + (s, [comps[0], comps[1], comps[2]], frame) +} + +fn select(u: &mut Ui, ids: &[NodeId]) { + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + u.frame(vec![]); +} + +/// The text painted this frame. +fn shows(u: &mut Ui, s: &str) -> Option { + let shapes = flat(&u.frame_output(vec![]).shapes); + has_text(&shapes, s).then(|| text_center(&shapes, s)) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); +} + +fn set_of(u: &Ui, c: NodeId) -> Option { + let doc = u.app.session.doc().unwrap(); + doc.node(c).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +#[test] +fn the_design_panel_combines_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, b]); + let at = shows(&mut u, COMBINE).expect("the Design panel offers Combine as variants"); + let undo = undo_len(&u); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert_eq!(u.app.session.selection(), vec![set]); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert!(shows(&mut u, COMBINE).is_none(), "a set selected: nothing to combine"); +} + +#[test] +fn the_design_panel_offers_combine_only_when_it_applies() { + // The cases are the engine's (`can_combine_variants`); this pins the panel to it. + let (s, [a, ..], frame) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, frame]); + assert!(shows(&mut u, COMBINE).is_none(), "a component and a frame"); +} + +#[test] +fn the_design_panel_adds_a_variant_to_a_lone_component() { + let (s, [a, ..], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a]); + let at = shows(&mut u, "Add variant").expect("a lone main component offers Add variant"); + click(&mut u, at); + let set = set_of(&u, a).expect("the component became a set"); + assert_eq!(u.app.session.doc().unwrap().node(set).unwrap().children.len(), 2); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn the_context_menu_lists_combine_for_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + // One component: no Combine, Create component as before. + select(&mut u, &[a]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + assert!(shows(&mut u, COMBINE).is_none()); + assert!(shows(&mut u, "Create component").is_some()); + u.app.ui.context_menu = None; + // Two components, right-clicked on one layer row (the menu gets only that row): judged on + // the selection, which is what the command combines. + select(&mut u, &[a, b]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + u.frame(vec![]); + let at = shows(&mut u, COMBINE).expect("the context menu offers Combine as variants"); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} diff --git a/docs/components-parity.md b/docs/components-parity.md index dcd93305..fad4c6fe 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -16,7 +16,7 @@ accounts). | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | -| Component sets / variants, add variant, switch | done | | +| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | | A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20da2f76..d38dc0bc 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -121,7 +121,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi diff --git a/packaging/server/install.sh b/packaging/server/install.sh index 11c489a3..826c2bb4 100755 --- a/packaging/server/install.sh +++ b/packaging/server/install.sh @@ -147,7 +147,7 @@ FILES="$HERE" # where the units and the Caddyfile come from from_release() { # download and check the release archive; sets NEW_BIN and FILES local version="$VERSION" name dir if [ -z "$version" ]; then - version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 + version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 [ -n "$version" ] || return 1 fi name="figcraft-server-$version-linux-$ARCH" @@ -155,8 +155,8 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE rm -rf "$dir" mkdir -p "$dir" say "Downloading $name" - curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 - curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 + curl --proto '=https' -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 + curl --proto '=https' -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 (cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt" tar -C "$dir" -xzf "$dir/$name.tar.gz" NEW_BIN="$dir/$name/figcraft-server" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..92562d54 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_figcraft +sonar.organization=storytold