diff --git a/Cargo.lock b/Cargo.lock index f8040354..9951aed0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,198 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -23,12 +209,207 @@ dependencies = [ "serde", ] +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -41,7 +422,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -50,12 +440,61 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytemuck" version = "1.25.2" @@ -82,12 +521,116 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "color" version = "0.3.3" @@ -103,6 +646,65 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -112,15 +714,90 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "drawcraft" version = "0.1.0" +dependencies = [ + "drawcraft-engine", + "drawcraft-ui-egui", + "eframe", + "egui", + "log", + "rfd", + "serde_json", +] [[package]] name = "drawcraft-cli" @@ -130,11 +807,11 @@ version = "0.1.0" name = "drawcraft-color" version = "0.1.0" dependencies = [ - "kurbo", + "kurbo 0.13.1", "proptest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -143,11 +820,11 @@ version = "0.1.0" dependencies = [ "drawcraft-color", "drawcraft-geom", - "kurbo", + "kurbo 0.13.1", "proptest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -157,7 +834,7 @@ dependencies = [ "drawcraft-doc", "drawcraft-geom", "drawcraft-pathops", - "kurbo", + "kurbo 0.13.1", "serde_json", ] @@ -174,11 +851,11 @@ dependencies = [ "drawcraft-svg", "drawcraft-text", "drawcraft-tools", - "kurbo", + "kurbo 0.13.1", "log", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -186,20 +863,21 @@ name = "drawcraft-format" version = "0.1.0" dependencies = [ "drawcraft-doc", + "drawcraft-geom", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] name = "drawcraft-geom" version = "0.1.0" dependencies = [ - "kurbo", + "kurbo 0.13.1", "proptest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -211,10 +889,10 @@ name = "drawcraft-pathops" version = "0.1.0" dependencies = [ "drawcraft-geom", - "kurbo", + "kurbo 0.13.1", "linesweeper", "proptest", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -223,8 +901,8 @@ version = "0.1.0" dependencies = [ "drawcraft-doc", "drawcraft-geom", - "kurbo", - "thiserror", + "kurbo 0.13.1", + "thiserror 2.0.21", ] [[package]] @@ -237,7 +915,7 @@ dependencies = [ "drawcraft-text", "image", "log", - "vello_cpu", + "vello_cpu 0.2.0", ] [[package]] @@ -248,10 +926,10 @@ dependencies = [ "drawcraft-doc", "drawcraft-geom", "image", - "kurbo", - "svgtypes", - "thiserror", - "usvg", + "kurbo 0.13.1", + "svgtypes 0.16.1", + "thiserror 2.0.21", + "usvg 0.48.1", ] [[package]] @@ -269,7 +947,7 @@ dependencies = [ "drawcraft-doc", "drawcraft-geom", "harfrust 0.13.3", - "kurbo", + "kurbo 0.13.1", "log", "skrifa 0.47.0", ] @@ -282,7 +960,7 @@ dependencies = [ "drawcraft-doc", "drawcraft-geom", "drawcraft-text", - "kurbo", + "kurbo 0.13.1", "serde", "serde_json", ] @@ -290,11 +968,261 @@ dependencies = [ [[package]] name = "drawcraft-ui-egui" version = "0.1.0" +dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-engine", + "drawcraft-format", + "drawcraft-geom", + "drawcraft-render", + "drawcraft-svg", + "drawcraft-text", + "drawcraft-tools", + "egui", + "egui_extras", + "log", + "serde", + "serde_json", +] [[package]] name = "drawcraft-web" version = "0.1.0" +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust 0.12.0", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu 0.1.0", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -302,9 +1230,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -314,12 +1248,38 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -335,6 +1295,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" @@ -395,6 +1361,98 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -428,6 +1486,33 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.1.0", +] + [[package]] name = "glifo" version = "0.3.0" @@ -436,13 +1521,105 @@ checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash", - "hashbrown", + "hashbrown 0.17.1", "log", "peniko", - "png", + "png 0.18.1", "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", ] [[package]] @@ -454,13 +1631,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "harfrust" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytemuck", "read-fonts 0.41.0", "smallvec", @@ -472,13 +1661,24 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytemuck", "once_cell", "read-fonts 0.43.3", "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -488,6 +1688,18 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "image" version = "0.25.10" @@ -501,7 +1713,8 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", + "tiff", "zune-core", "zune-jpeg", ] @@ -516,18 +1729,150 @@ dependencies = [ "quick-error 2.0.1", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -547,6 +1892,34 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -560,18 +1933,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" dependencies = [ "arrayvec", - "kurbo", + "kurbo 0.13.1", "polycool", - "rustc-hash", + "rustc-hash 2.1.3", "smallvec", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -593,6 +1987,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -623,6 +2026,80 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-traits" version = "0.2.19" @@ -630,6 +2107,340 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -638,6 +2449,64 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "peniko" version = "0.6.1" @@ -646,11 +2515,60 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "bytemuck", "color", - "kurbo", + "kurbo 0.13.1", "linebender_resource_handle", "smallvec", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -658,18 +2576,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] -name = "png" -version = "0.18.1" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ - "bitflags", + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "polycool" version = "0.4.0" @@ -679,6 +2685,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -688,6 +2709,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -697,15 +2733,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", "num-traits", "rand", "rand_chacha", @@ -734,6 +2776,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -793,6 +2844,30 @@ dependencies = [ "rand_core", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -826,12 +2901,95 @@ dependencies = [ "once_cell", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -847,25 +3005,59 @@ dependencies = [ "memchr", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -878,6 +3070,39 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -921,12 +3146,55 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -962,6 +3230,12 @@ dependencies = [ "read-fonts 0.44.0", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "slotmap" version = "1.1.1" @@ -980,6 +3254,93 @@ dependencies = [ "serde", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" @@ -989,13 +3350,23 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -1030,8 +3401,26 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -1040,7 +3429,18 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1054,6 +3454,46 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tiny-skia-path" version = "0.12.0" @@ -1071,6 +3511,97 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -1083,6 +3614,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -1095,25 +3632,59 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-vo" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path 0.11.4", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" dependencies = [ - "base64", + "base64 0.23.1", "data-url", "flate2", "fontdb", "harfrust 0.12.0", - "imagesize", - "kurbo", + "imagesize 0.15.0", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.1", @@ -1121,14 +3692,40 @@ dependencies = [ "siphasher", "skrifa 0.44.0", "strict-num", - "svgtypes", - "tiny-skia-path", + "svgtypes 0.16.1", + "tiny-skia-path 0.12.0", "unicode-bidi", "unicode-script", "unicode-vo", "xmlwriter", ] +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_common" version = "0.2.0" @@ -1140,9 +3737,21 @@ dependencies = [ "guillotiere", "log", "peniko", - "png", + "png 0.18.1", "smallvec", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo 0.2.0", + "hashbrown 0.17.1", + "vello_common 0.1.0", ] [[package]] @@ -1152,10 +3761,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo", - "hashbrown", - "png", - "vello_common", + "glifo 0.3.0", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.2.0", ] [[package]] @@ -1173,6 +3782,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -1182,18 +3801,543 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +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", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1203,12 +4347,273 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +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", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "xmlwriter" version = "0.1.0" @@ -1222,6 +4627,112 @@ dependencies = [ "serde_json", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -1268,3 +4779,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/apps/drawcraft/Cargo.toml b/apps/drawcraft/Cargo.toml index ed96ef42..7671f72e 100644 --- a/apps/drawcraft/Cargo.toml +++ b/apps/drawcraft/Cargo.toml @@ -4,8 +4,18 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false +default-run = "drawcraft" [lints] workspace = true [dependencies] +drawcraft-engine = { workspace = true } +drawcraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } diff --git a/apps/drawcraft/src/control_server.rs b/apps/drawcraft/src/control_server.rs new file mode 100644 index 00000000..81df35b4 --- /dev/null +++ b/apps/drawcraft/src/control_server.rs @@ -0,0 +1,62 @@ +//! Loopback JSON-lines control server: one request per line, one reply per line. +//! This is the transport the MCP server (`drawcraft mcp --connect`) wraps. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use drawcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("drawcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + return rx; + } + }; + eprintln!("drawcraft: control server listening on 127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || serve(stream, tx, ctx)); + } + }); + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index f328e4d9..af8a6548 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -1 +1,84 @@ -fn main() {} +//! DrawCraft desktop app. +//! +//! Usage: `drawcraft [--control ] [files…]` +//! +//! `--control ` (or `DRAWCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! See `drawcraft_ui_egui::control` for the methods. + +mod control_server; + +use drawcraft_engine::Session; +use drawcraft_ui_egui::{DrawcraftApp, Services}; + +struct App(DrawcraftApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + if self.0.ui.status == "quit" { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|| rfd::FileDialog::new().add_filter("All supported", &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]).pick_file().map(|p| p.to_string_lossy().to_string()))), + pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + } +} + +fn main() -> eframe::Result { + let mut control_port: Option = std::env::var("DRAWCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--version" => { + println!("drawcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + _ => files.push(a), + } + } + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("DrawCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([800.0, 500.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false), + ..Default::default() + }; + eframe::run_native( + "DrawCraft", + options, + Box::new(move |cc| { + let mut app = DrawcraftApp::new(Session::new(), services()); + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + for f in files { + if let Err(e) = drawcraft_ui_egui::io::open_path(&mut app, &f) { + eprintln!("drawcraft: {f}: {e}"); + } + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/assets/icons/LICENSE-lucide.txt b/assets/icons/LICENSE-lucide.txt new file mode 100644 index 00000000..718bb3f0 --- /dev/null +++ b/assets/icons/LICENSE-lucide.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/icons/align-center-horizontal.svg b/assets/icons/align-center-horizontal.svg new file mode 100644 index 00000000..670ae48d --- /dev/null +++ b/assets/icons/align-center-horizontal.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/align-center-vertical.svg b/assets/icons/align-center-vertical.svg new file mode 100644 index 00000000..703ed40c --- /dev/null +++ b/assets/icons/align-center-vertical.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/align-end-horizontal.svg b/assets/icons/align-end-horizontal.svg new file mode 100644 index 00000000..e59dc798 --- /dev/null +++ b/assets/icons/align-end-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-end-vertical.svg b/assets/icons/align-end-vertical.svg new file mode 100644 index 00000000..c3b4b3d9 --- /dev/null +++ b/assets/icons/align-end-vertical.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-horizontal-justify-center.svg b/assets/icons/align-horizontal-justify-center.svg new file mode 100644 index 00000000..d68986eb --- /dev/null +++ b/assets/icons/align-horizontal-justify-center.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-horizontal-justify-end.svg b/assets/icons/align-horizontal-justify-end.svg new file mode 100644 index 00000000..7e5b2af2 --- /dev/null +++ b/assets/icons/align-horizontal-justify-end.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-horizontal-justify-start.svg b/assets/icons/align-horizontal-justify-start.svg new file mode 100644 index 00000000..c40c305a --- /dev/null +++ b/assets/icons/align-horizontal-justify-start.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-start-horizontal.svg b/assets/icons/align-start-horizontal.svg new file mode 100644 index 00000000..322a242a --- /dev/null +++ b/assets/icons/align-start-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-start-vertical.svg b/assets/icons/align-start-vertical.svg new file mode 100644 index 00000000..ab9b7e8b --- /dev/null +++ b/assets/icons/align-start-vertical.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-vertical-justify-center.svg b/assets/icons/align-vertical-justify-center.svg new file mode 100644 index 00000000..0df22a36 --- /dev/null +++ b/assets/icons/align-vertical-justify-center.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-vertical-justify-end.svg b/assets/icons/align-vertical-justify-end.svg new file mode 100644 index 00000000..ab57a7d6 --- /dev/null +++ b/assets/icons/align-vertical-justify-end.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-vertical-justify-start.svg b/assets/icons/align-vertical-justify-start.svg new file mode 100644 index 00000000..97aaca65 --- /dev/null +++ b/assets/icons/align-vertical-justify-start.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/arrow-left-right.svg b/assets/icons/arrow-left-right.svg new file mode 100644 index 00000000..4005f9e5 --- /dev/null +++ b/assets/icons/arrow-left-right.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/arrow-up-down.svg b/assets/icons/arrow-up-down.svg new file mode 100644 index 00000000..f58abde3 --- /dev/null +++ b/assets/icons/arrow-up-down.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/blend.svg b/assets/icons/blend.svg new file mode 100644 index 00000000..09d134f4 --- /dev/null +++ b/assets/icons/blend.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/brush.svg b/assets/icons/brush.svg new file mode 100644 index 00000000..fcb92ff3 --- /dev/null +++ b/assets/icons/brush.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/chart-column.svg b/assets/icons/chart-column.svg new file mode 100644 index 00000000..b14a1408 --- /dev/null +++ b/assets/icons/chart-column.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg new file mode 100644 index 00000000..49a0003f --- /dev/null +++ b/assets/icons/chevron-down.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/chevron-left.svg b/assets/icons/chevron-left.svg new file mode 100644 index 00000000..161890f2 --- /dev/null +++ b/assets/icons/chevron-left.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/chevron-right.svg b/assets/icons/chevron-right.svg new file mode 100644 index 00000000..cd1e6892 --- /dev/null +++ b/assets/icons/chevron-right.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/chevrons-left.svg b/assets/icons/chevrons-left.svg new file mode 100644 index 00000000..49309dcc --- /dev/null +++ b/assets/icons/chevrons-left.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/chevrons-right.svg b/assets/icons/chevrons-right.svg new file mode 100644 index 00000000..ba4e2dab --- /dev/null +++ b/assets/icons/chevrons-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/circle.svg b/assets/icons/circle.svg new file mode 100644 index 00000000..4e464d6f --- /dev/null +++ b/assets/icons/circle.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/clipboard-paste.svg b/assets/icons/clipboard-paste.svg new file mode 100644 index 00000000..350c2ca5 --- /dev/null +++ b/assets/icons/clipboard-paste.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/cloud.svg b/assets/icons/cloud.svg new file mode 100644 index 00000000..14b4e191 --- /dev/null +++ b/assets/icons/cloud.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/combine.svg b/assets/icons/combine.svg new file mode 100644 index 00000000..0c553f22 --- /dev/null +++ b/assets/icons/combine.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 00000000..dd30be8c --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/dc-align.svg b/assets/icons/dc-align.svg new file mode 100644 index 00000000..e1757d0e --- /dev/null +++ b/assets/icons/dc-align.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-anchor.svg b/assets/icons/dc-anchor.svg new file mode 100644 index 00000000..54812876 --- /dev/null +++ b/assets/icons/dc-anchor.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-appearance.svg b/assets/icons/dc-appearance.svg new file mode 100644 index 00000000..12054b4e --- /dev/null +++ b/assets/icons/dc-appearance.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-arc.svg b/assets/icons/dc-arc.svg new file mode 100644 index 00000000..bd429945 --- /dev/null +++ b/assets/icons/dc-arc.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-artboards.svg b/assets/icons/dc-artboards.svg new file mode 100644 index 00000000..e59b49ef --- /dev/null +++ b/assets/icons/dc-artboards.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-blend.svg b/assets/icons/dc-blend.svg new file mode 100644 index 00000000..8933112e --- /dev/null +++ b/assets/icons/dc-blend.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-bloat.svg b/assets/icons/dc-bloat.svg new file mode 100644 index 00000000..e2db54b4 --- /dev/null +++ b/assets/icons/dc-bloat.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-color-guide.svg b/assets/icons/dc-color-guide.svg new file mode 100644 index 00000000..ea8d3efc --- /dev/null +++ b/assets/icons/dc-color-guide.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-crystallize.svg b/assets/icons/dc-crystallize.svg new file mode 100644 index 00000000..74341ce2 --- /dev/null +++ b/assets/icons/dc-crystallize.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-direct.svg b/assets/icons/dc-direct.svg new file mode 100644 index 00000000..6a7239e7 --- /dev/null +++ b/assets/icons/dc-direct.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-draw-behind.svg b/assets/icons/dc-draw-behind.svg new file mode 100644 index 00000000..c835ab5d --- /dev/null +++ b/assets/icons/dc-draw-behind.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-draw-inside.svg b/assets/icons/dc-draw-inside.svg new file mode 100644 index 00000000..ef09b1d5 --- /dev/null +++ b/assets/icons/dc-draw-inside.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-draw-normal.svg b/assets/icons/dc-draw-normal.svg new file mode 100644 index 00000000..ccc7e93e --- /dev/null +++ b/assets/icons/dc-draw-normal.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-ellipse.svg b/assets/icons/dc-ellipse.svg new file mode 100644 index 00000000..d0bcf135 --- /dev/null +++ b/assets/icons/dc-ellipse.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-fill-none.svg b/assets/icons/dc-fill-none.svg new file mode 100644 index 00000000..e58ff7b6 --- /dev/null +++ b/assets/icons/dc-fill-none.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-flare.svg b/assets/icons/dc-flare.svg new file mode 100644 index 00000000..ebefebe0 --- /dev/null +++ b/assets/icons/dc-flare.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-free-transform.svg b/assets/icons/dc-free-transform.svg new file mode 100644 index 00000000..b9cffa94 --- /dev/null +++ b/assets/icons/dc-free-transform.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-gradient.svg b/assets/icons/dc-gradient.svg new file mode 100644 index 00000000..9a46a3f6 --- /dev/null +++ b/assets/icons/dc-gradient.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-graphic-styles.svg b/assets/icons/dc-graphic-styles.svg new file mode 100644 index 00000000..eeb7edcf --- /dev/null +++ b/assets/icons/dc-graphic-styles.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-group-select.svg b/assets/icons/dc-group-select.svg new file mode 100644 index 00000000..4e13de1b --- /dev/null +++ b/assets/icons/dc-group-select.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-join.svg b/assets/icons/dc-join.svg new file mode 100644 index 00000000..aaa681a0 --- /dev/null +++ b/assets/icons/dc-join.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-knife.svg b/assets/icons/dc-knife.svg new file mode 100644 index 00000000..590acc4e --- /dev/null +++ b/assets/icons/dc-knife.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-line.svg b/assets/icons/dc-line.svg new file mode 100644 index 00000000..6e39f8b7 --- /dev/null +++ b/assets/icons/dc-line.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-live-bucket.svg b/assets/icons/dc-live-bucket.svg new file mode 100644 index 00000000..5e4d1007 --- /dev/null +++ b/assets/icons/dc-live-bucket.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-live-select.svg b/assets/icons/dc-live-select.svg new file mode 100644 index 00000000..e7389f6a --- /dev/null +++ b/assets/icons/dc-live-select.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-measure.svg b/assets/icons/dc-measure.svg new file mode 100644 index 00000000..452b68a0 --- /dev/null +++ b/assets/icons/dc-measure.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-mesh.svg b/assets/icons/dc-mesh.svg new file mode 100644 index 00000000..f66078e6 --- /dev/null +++ b/assets/icons/dc-mesh.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-path-eraser.svg b/assets/icons/dc-path-eraser.svg new file mode 100644 index 00000000..b162410c --- /dev/null +++ b/assets/icons/dc-path-eraser.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pathfinder.svg b/assets/icons/dc-pathfinder.svg new file mode 100644 index 00000000..e397311c --- /dev/null +++ b/assets/icons/dc-pathfinder.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pen-add.svg b/assets/icons/dc-pen-add.svg new file mode 100644 index 00000000..780bbc32 --- /dev/null +++ b/assets/icons/dc-pen-add.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pen-delete.svg b/assets/icons/dc-pen-delete.svg new file mode 100644 index 00000000..601e9aea --- /dev/null +++ b/assets/icons/dc-pen-delete.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-perspective.svg b/assets/icons/dc-perspective.svg new file mode 100644 index 00000000..cf623a97 --- /dev/null +++ b/assets/icons/dc-perspective.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-polar-grid.svg b/assets/icons/dc-polar-grid.svg new file mode 100644 index 00000000..a1d2014f --- /dev/null +++ b/assets/icons/dc-polar-grid.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-polygon.svg b/assets/icons/dc-polygon.svg new file mode 100644 index 00000000..37c1a184 --- /dev/null +++ b/assets/icons/dc-polygon.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-pucker.svg b/assets/icons/dc-pucker.svg new file mode 100644 index 00000000..d8d265c4 --- /dev/null +++ b/assets/icons/dc-pucker.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-puppet.svg b/assets/icons/dc-puppet.svg new file mode 100644 index 00000000..9055e5d2 --- /dev/null +++ b/assets/icons/dc-puppet.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rect-grid.svg b/assets/icons/dc-rect-grid.svg new file mode 100644 index 00000000..c7934f68 --- /dev/null +++ b/assets/icons/dc-rect-grid.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-reference-point.svg b/assets/icons/dc-reference-point.svg new file mode 100644 index 00000000..c6093477 --- /dev/null +++ b/assets/icons/dc-reference-point.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-reshape.svg b/assets/icons/dc-reshape.svg new file mode 100644 index 00000000..a76df44e --- /dev/null +++ b/assets/icons/dc-reshape.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rotate-view.svg b/assets/icons/dc-rotate-view.svg new file mode 100644 index 00000000..39e4866c --- /dev/null +++ b/assets/icons/dc-rotate-view.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rounded-rect.svg b/assets/icons/dc-rounded-rect.svg new file mode 100644 index 00000000..64f831f2 --- /dev/null +++ b/assets/icons/dc-rounded-rect.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-scallop.svg b/assets/icons/dc-scallop.svg new file mode 100644 index 00000000..9fdcf152 --- /dev/null +++ b/assets/icons/dc-scallop.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-screen-mode.svg b/assets/icons/dc-screen-mode.svg new file mode 100644 index 00000000..61bfe471 --- /dev/null +++ b/assets/icons/dc-screen-mode.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-selection.svg b/assets/icons/dc-selection.svg new file mode 100644 index 00000000..d3c239f5 --- /dev/null +++ b/assets/icons/dc-selection.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-shape-builder.svg b/assets/icons/dc-shape-builder.svg new file mode 100644 index 00000000..6a26ee78 --- /dev/null +++ b/assets/icons/dc-shape-builder.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-shear.svg b/assets/icons/dc-shear.svg new file mode 100644 index 00000000..21bcfe68 --- /dev/null +++ b/assets/icons/dc-shear.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-smooth.svg b/assets/icons/dc-smooth.svg new file mode 100644 index 00000000..98752636 --- /dev/null +++ b/assets/icons/dc-smooth.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-stroke.svg b/assets/icons/dc-stroke.svg new file mode 100644 index 00000000..46a081d6 --- /dev/null +++ b/assets/icons/dc-stroke.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-swap.svg b/assets/icons/dc-swap.svg new file mode 100644 index 00000000..0696d760 --- /dev/null +++ b/assets/icons/dc-swap.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-symbol-sprayer.svg b/assets/icons/dc-symbol-sprayer.svg new file mode 100644 index 00000000..82c02937 --- /dev/null +++ b/assets/icons/dc-symbol-sprayer.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-touch-type.svg b/assets/icons/dc-touch-type.svg new file mode 100644 index 00000000..f5af072c --- /dev/null +++ b/assets/icons/dc-touch-type.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-transform-panel.svg b/assets/icons/dc-transform-panel.svg new file mode 100644 index 00000000..ff261e18 --- /dev/null +++ b/assets/icons/dc-transform-panel.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-transparency.svg b/assets/icons/dc-transparency.svg new file mode 100644 index 00000000..6171fa49 --- /dev/null +++ b/assets/icons/dc-transparency.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-twirl.svg b/assets/icons/dc-twirl.svg new file mode 100644 index 00000000..cda0c6b5 --- /dev/null +++ b/assets/icons/dc-twirl.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-area.svg b/assets/icons/dc-type-area.svg new file mode 100644 index 00000000..f37960da --- /dev/null +++ b/assets/icons/dc-type-area.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-path.svg b/assets/icons/dc-type-path.svg new file mode 100644 index 00000000..42584412 --- /dev/null +++ b/assets/icons/dc-type-path.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-type-vertical.svg b/assets/icons/dc-type-vertical.svg new file mode 100644 index 00000000..d7285553 --- /dev/null +++ b/assets/icons/dc-type-vertical.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-width.svg b/assets/icons/dc-width.svg new file mode 100644 index 00000000..fc987d12 --- /dev/null +++ b/assets/icons/dc-width.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-wrinkle.svg b/assets/icons/dc-wrinkle.svg new file mode 100644 index 00000000..2b902d87 --- /dev/null +++ b/assets/icons/dc-wrinkle.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg new file mode 100644 index 00000000..c02b701e --- /dev/null +++ b/assets/icons/ellipsis.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/eraser.svg b/assets/icons/eraser.svg new file mode 100644 index 00000000..23095e26 --- /dev/null +++ b/assets/icons/eraser.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/eye-off.svg b/assets/icons/eye-off.svg new file mode 100644 index 00000000..03b7f08d --- /dev/null +++ b/assets/icons/eye-off.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 00000000..e5b66aca --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/file-plus.svg b/assets/icons/file-plus.svg new file mode 100644 index 00000000..75a6098c --- /dev/null +++ b/assets/icons/file-plus.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/flip-horizontal-2.svg b/assets/icons/flip-horizontal-2.svg new file mode 100644 index 00000000..c33c0de7 --- /dev/null +++ b/assets/icons/flip-horizontal-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/flip-vertical-2.svg b/assets/icons/flip-vertical-2.svg new file mode 100644 index 00000000..0264c3ea --- /dev/null +++ b/assets/icons/flip-vertical-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svg new file mode 100644 index 00000000..457adcc1 --- /dev/null +++ b/assets/icons/folder-open.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/frame.svg b/assets/icons/frame.svg new file mode 100644 index 00000000..658524ce --- /dev/null +++ b/assets/icons/frame.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/grid-3x3.svg b/assets/icons/grid-3x3.svg new file mode 100644 index 00000000..9bff390a --- /dev/null +++ b/assets/icons/grid-3x3.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/group.svg b/assets/icons/group.svg new file mode 100644 index 00000000..7450fd77 --- /dev/null +++ b/assets/icons/group.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/hand.svg b/assets/icons/hand.svg new file mode 100644 index 00000000..2caf57dd --- /dev/null +++ b/assets/icons/hand.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/hexagon.svg b/assets/icons/hexagon.svg new file mode 100644 index 00000000..8b9f8176 --- /dev/null +++ b/assets/icons/hexagon.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/history.svg b/assets/icons/history.svg new file mode 100644 index 00000000..95da3487 --- /dev/null +++ b/assets/icons/history.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/house.svg b/assets/icons/house.svg new file mode 100644 index 00000000..bbe4a2c4 --- /dev/null +++ b/assets/icons/house.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 00000000..17d18d34 --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/info.svg b/assets/icons/info.svg new file mode 100644 index 00000000..cde7799b --- /dev/null +++ b/assets/icons/info.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/lasso.svg b/assets/icons/lasso.svg new file mode 100644 index 00000000..4711dabc --- /dev/null +++ b/assets/icons/lasso.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/layers.svg b/assets/icons/layers.svg new file mode 100644 index 00000000..66b9a798 --- /dev/null +++ b/assets/icons/layers.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/layout-grid.svg b/assets/icons/layout-grid.svg new file mode 100644 index 00000000..950226b6 --- /dev/null +++ b/assets/icons/layout-grid.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/library.svg b/assets/icons/library.svg new file mode 100644 index 00000000..efbcc306 --- /dev/null +++ b/assets/icons/library.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/link-2-off.svg b/assets/icons/link-2-off.svg new file mode 100644 index 00000000..68bab875 --- /dev/null +++ b/assets/icons/link-2-off.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 00000000..46cc89f6 --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/lock-open.svg b/assets/icons/lock-open.svg new file mode 100644 index 00000000..2d5de4b0 --- /dev/null +++ b/assets/icons/lock-open.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 00000000..a0a5ec81 --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/map.svg b/assets/icons/map.svg new file mode 100644 index 00000000..f742a249 --- /dev/null +++ b/assets/icons/map.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/maximize-2.svg b/assets/icons/maximize-2.svg new file mode 100644 index 00000000..cab834d8 --- /dev/null +++ b/assets/icons/maximize-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/menu.svg b/assets/icons/menu.svg new file mode 100644 index 00000000..b6e26453 --- /dev/null +++ b/assets/icons/menu.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/minus.svg b/assets/icons/minus.svg new file mode 100644 index 00000000..720a806f --- /dev/null +++ b/assets/icons/minus.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/monitor.svg b/assets/icons/monitor.svg new file mode 100644 index 00000000..6d9d3902 --- /dev/null +++ b/assets/icons/monitor.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/move-diagonal-2.svg b/assets/icons/move-diagonal-2.svg new file mode 100644 index 00000000..df85faef --- /dev/null +++ b/assets/icons/move-diagonal-2.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/move.svg b/assets/icons/move.svg new file mode 100644 index 00000000..c808bf82 --- /dev/null +++ b/assets/icons/move.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/paint-bucket.svg b/assets/icons/paint-bucket.svg new file mode 100644 index 00000000..c43f9392 --- /dev/null +++ b/assets/icons/paint-bucket.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/paintbrush.svg b/assets/icons/paintbrush.svg new file mode 100644 index 00000000..5b48e9d8 --- /dev/null +++ b/assets/icons/paintbrush.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/palette.svg b/assets/icons/palette.svg new file mode 100644 index 00000000..0a76530b --- /dev/null +++ b/assets/icons/palette.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/pen-tool.svg b/assets/icons/pen-tool.svg new file mode 100644 index 00000000..4a59b372 --- /dev/null +++ b/assets/icons/pen-tool.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/pencil.svg b/assets/icons/pencil.svg new file mode 100644 index 00000000..10445cac --- /dev/null +++ b/assets/icons/pencil.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 00000000..1a1b38b1 --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 00000000..7c0602af --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/pipette.svg b/assets/icons/pipette.svg new file mode 100644 index 00000000..9db16f0d --- /dev/null +++ b/assets/icons/pipette.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 00000000..545c7940 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/printer.svg b/assets/icons/printer.svg new file mode 100644 index 00000000..25a37798 --- /dev/null +++ b/assets/icons/printer.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/redo-2.svg b/assets/icons/redo-2.svg new file mode 100644 index 00000000..a7e0f519 --- /dev/null +++ b/assets/icons/redo-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/rotate-3d.svg b/assets/icons/rotate-3d.svg new file mode 100644 index 00000000..ed05d159 --- /dev/null +++ b/assets/icons/rotate-3d.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/rotate-ccw.svg b/assets/icons/rotate-ccw.svg new file mode 100644 index 00000000..99a2e273 --- /dev/null +++ b/assets/icons/rotate-ccw.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/rotate-cw.svg b/assets/icons/rotate-cw.svg new file mode 100644 index 00000000..f5f750d4 --- /dev/null +++ b/assets/icons/rotate-cw.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/ruler.svg b/assets/icons/ruler.svg new file mode 100644 index 00000000..573ee24f --- /dev/null +++ b/assets/icons/ruler.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/save.svg b/assets/icons/save.svg new file mode 100644 index 00000000..15bdf54c --- /dev/null +++ b/assets/icons/save.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/scaling.svg b/assets/icons/scaling.svg new file mode 100644 index 00000000..7a7461fb --- /dev/null +++ b/assets/icons/scaling.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/scissors.svg b/assets/icons/scissors.svg new file mode 100644 index 00000000..5d798fef --- /dev/null +++ b/assets/icons/scissors.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 00000000..a079713e --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 00000000..de286c81 --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/shapes.svg b/assets/icons/shapes.svg new file mode 100644 index 00000000..563b466f --- /dev/null +++ b/assets/icons/shapes.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/share-2.svg b/assets/icons/share-2.svg new file mode 100644 index 00000000..c57325fa --- /dev/null +++ b/assets/icons/share-2.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/slice.svg b/assets/icons/slice.svg new file mode 100644 index 00000000..a6e75d1a --- /dev/null +++ b/assets/icons/slice.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/sparkles.svg b/assets/icons/sparkles.svg new file mode 100644 index 00000000..5b1abc1e --- /dev/null +++ b/assets/icons/sparkles.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/spline-pointer.svg b/assets/icons/spline-pointer.svg new file mode 100644 index 00000000..d42e9dcb --- /dev/null +++ b/assets/icons/spline-pointer.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/spline.svg b/assets/icons/spline.svg new file mode 100644 index 00000000..ca24df5e --- /dev/null +++ b/assets/icons/spline.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/spray-can.svg b/assets/icons/spray-can.svg new file mode 100644 index 00000000..e637aaa7 --- /dev/null +++ b/assets/icons/spray-can.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/assets/icons/square-dashed.svg b/assets/icons/square-dashed.svg new file mode 100644 index 00000000..8b5b79eb --- /dev/null +++ b/assets/icons/square-dashed.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/assets/icons/square.svg b/assets/icons/square.svg new file mode 100644 index 00000000..40f1eb9d --- /dev/null +++ b/assets/icons/square.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/squares-exclude.svg b/assets/icons/squares-exclude.svg new file mode 100644 index 00000000..29f87ee4 --- /dev/null +++ b/assets/icons/squares-exclude.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/squares-intersect.svg b/assets/icons/squares-intersect.svg new file mode 100644 index 00000000..0295af56 --- /dev/null +++ b/assets/icons/squares-intersect.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/assets/icons/squares-subtract.svg b/assets/icons/squares-subtract.svg new file mode 100644 index 00000000..89ec7a98 --- /dev/null +++ b/assets/icons/squares-subtract.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/squares-unite.svg b/assets/icons/squares-unite.svg new file mode 100644 index 00000000..5c541d60 --- /dev/null +++ b/assets/icons/squares-unite.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/squircle.svg b/assets/icons/squircle.svg new file mode 100644 index 00000000..2a4766e4 --- /dev/null +++ b/assets/icons/squircle.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/star.svg b/assets/icons/star.svg new file mode 100644 index 00000000..3502ccca --- /dev/null +++ b/assets/icons/star.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/sun.svg b/assets/icons/sun.svg new file mode 100644 index 00000000..7e70994c --- /dev/null +++ b/assets/icons/sun.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/assets/icons/swatch-book.svg b/assets/icons/swatch-book.svg new file mode 100644 index 00000000..98eb4426 --- /dev/null +++ b/assets/icons/swatch-book.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/text-cursor-input.svg b/assets/icons/text-cursor-input.svg new file mode 100644 index 00000000..9e77e6d5 --- /dev/null +++ b/assets/icons/text-cursor-input.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/tornado.svg b/assets/icons/tornado.svg new file mode 100644 index 00000000..9b8ce34c --- /dev/null +++ b/assets/icons/tornado.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/trash-2.svg b/assets/icons/trash-2.svg new file mode 100644 index 00000000..9685b29e --- /dev/null +++ b/assets/icons/trash-2.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/triangle.svg b/assets/icons/triangle.svg new file mode 100644 index 00000000..0d43fa26 --- /dev/null +++ b/assets/icons/triangle.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/type.svg b/assets/icons/type.svg new file mode 100644 index 00000000..ca1b376f --- /dev/null +++ b/assets/icons/type.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/undo-2.svg b/assets/icons/undo-2.svg new file mode 100644 index 00000000..cd92f939 --- /dev/null +++ b/assets/icons/undo-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/ungroup.svg b/assets/icons/ungroup.svg new file mode 100644 index 00000000..6312dfe6 --- /dev/null +++ b/assets/icons/ungroup.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/wand-sparkles.svg b/assets/icons/wand-sparkles.svg new file mode 100644 index 00000000..7e73437e --- /dev/null +++ b/assets/icons/wand-sparkles.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/assets/icons/waves.svg b/assets/icons/waves.svg new file mode 100644 index 00000000..2ca2d2d7 --- /dev/null +++ b/assets/icons/waves.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg new file mode 100644 index 00000000..be304097 --- /dev/null +++ b/assets/icons/x.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/zoom-in.svg b/assets/icons/zoom-in.svg new file mode 100644 index 00000000..1a1ec0e5 --- /dev/null +++ b/assets/icons/zoom-in.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index b5d8268e..ebe36679 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -3,7 +3,7 @@ name = "drawcraft-format" version.workspace = true edition.workspace = true license.workspace = true -description = "DrawCraft native .drawcraft format" +description = "DrawCraft native .drawcraft format (versioned JSON, embedded images)" [lints] workspace = true @@ -13,3 +13,6 @@ drawcraft-doc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +drawcraft-geom = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index e9fdc3ca..25316e4d 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,2 +1,159 @@ -//! DrawCraft native .drawcraft format +//! The native `.drawcraft` format. +//! +//! A `.drawcraft` file is UTF-8 JSON: +//! ```json +//! { "format": "drawcraft", "version": 1, "generator": "DrawCraft 0.1.0", +//! "document": { …drawcraft_doc::Document… }, +//! "images": { "": { "mime": "image/png", "data": "" } } } +//! ``` +//! It is lossless for everything in the document model and preserves unknown fields under +//! `document.unknown`. Readers must reject files whose `version` is newer than they support. #![forbid(unsafe_code)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use drawcraft_doc::{Document, ImageBlob}; +use serde::{Deserialize, Serialize}; + +pub const VERSION: u32 = 1; +pub const EXTENSION: &str = "drawcraft"; + +#[derive(Debug, thiserror::Error)] +pub enum FormatError { + #[error("not a DrawCraft file: {0}")] + NotDrawcraft(String), + #[error("file version {0} is newer than this DrawCraft supports ({VERSION})")] + TooNew(u32), + #[error("invalid image data for `{0}`")] + BadImage(String), +} + +#[derive(Serialize, Deserialize)] +struct Image { + mime: String, + data: String, +} + +#[derive(Serialize, Deserialize)] +struct File { + format: String, + version: u32, + #[serde(default)] + generator: String, + document: Document, + #[serde(default)] + images: BTreeMap, +} + +/// Serialize a document (pretty = human-diffable). +pub fn save(doc: &Document, pretty: bool) -> Vec { + let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect(); + let f = File { format: "drawcraft".into(), version: VERSION, generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), document: doc.clone(), images }; + if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() } +} + +pub fn load(bytes: &[u8]) -> Result { + let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotDrawcraft(e.to_string()))?; + if f.format != "drawcraft" { + return Err(FormatError::NotDrawcraft(format!("format is `{}`", f.format))); + } + if f.version > VERSION { + return Err(FormatError::TooNew(f.version)); + } + let mut doc = f.document; + for (k, img) in f.images { + let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?; + doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) }); + } + doc.fix_next_id(); + Ok(doc) +} + +/// Does this look like a `.drawcraft` file? +pub fn sniff(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(256)]; + std::str::from_utf8(head).is_ok_and(|s| s.trim_start().starts_with('{') && s.contains("\"drawcraft\"")) +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +pub fn base64_decode(s: &str) -> Option> { + let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32); + let clean: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if !clean.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(clean.len() / 4 * 3); + for c in clean.chunks(4) { + let mut n = 0u32; + let mut pad = 0; + for &b in c { + n <<= 6; + if b == b'=' { + pad += 1; + } else { + n |= val(b)?; + } + } + out.push((n >> 16) as u8); + if pad < 2 { + out.push((n >> 8) as u8); + } + if pad < 1 { + out.push(n as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use drawcraft_doc::{Appearance, Node}; + use drawcraft_geom::{Rect, shapes}; + + #[test] + fn roundtrip() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![1, 2, 3, 250]) }); + let bytes = save(&d, true); + assert!(sniff(&bytes)); + let back = load(&bytes).unwrap(); + assert_eq!(back.node_count(), d.node_count()); + assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 250]); + assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); + } + + #[test] + fn rejects_foreign_and_future() { + assert!(load(b"{}").is_err()); + let d = Document::new(1.0, 1.0); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["version"] = 99.into(); + assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(FormatError::TooNew(99)))); + } + + #[test] + fn base64() { + for s in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] { + assert_eq!(base64_decode(&base64_encode(s)).unwrap(), s); + } + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 03a0868f..dc508e92 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -3,8 +3,23 @@ name = "drawcraft-ui-egui" version.workspace = true edition.workspace = true license.workspace = true +description = "DrawCraft egui frontend (Illustrator-style UI); swappable — nothing below depends on it" [lints] workspace = true [dependencies] +drawcraft-geom = { workspace = true } +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-render = { workspace = true } +drawcraft-text = { workspace = true } +drawcraft-tools = { workspace = true } +drawcraft-engine = { workspace = true } +drawcraft-svg = { workspace = true } +drawcraft-format = { workspace = true } +egui = { workspace = true } +egui_extras = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 00000000..65c371ec --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,748 @@ +//! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas +//! selection visuals (bounding box, anchors, handles, smart-guide style labels). + +use drawcraft_doc::{Node, NodeKind}; +use drawcraft_geom::{Affine, BezPath, PathEl, Point, Rect}; +use drawcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; +use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use crate::state::View; +use crate::theme::{self, Tokens}; +use crate::{CacheKey, DrawcraftApp, now_ms, widgets}; + +const RULER: f32 = 16.0; + +/// Screen ↔ document mapping for one frame. +#[derive(Clone, Copy, Debug)] +pub struct Xf { + pub rect: egui::Rect, + pub zoom: f64, + pub center: Point, +} + +impl Xf { + pub fn to_screen(&self, p: Point) -> Pos2 { + let c = self.rect.center(); + pos2(c.x + ((p.x - self.center.x) * self.zoom) as f32, c.y + ((p.y - self.center.y) * self.zoom) as f32) + } + pub fn to_doc(&self, p: Pos2) -> Point { + let c = self.rect.center(); + Point::new(self.center.x + (p.x - c.x) as f64 / self.zoom, self.center.y + (p.y - c.y) as f64 / self.zoom) + } + pub fn rect_to_screen(&self, r: Rect) -> egui::Rect { + egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1))) + } + /// Affine mapping document points to screen points. + pub fn affine(&self) -> Affine { + let c = self.rect.center(); + Affine::translate((c.x as f64, c.y as f64)) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Drag { + Tool, + Pan { start: Pos2, center: Point }, + ZoomBox { start: Pos2 }, + /// Cmd held: temporary selection tool; restore this tool on release. + TempSelect, +} + +fn drag_id() -> egui::Id { + egui::Id::new("canvas-drag") +} +fn temp_tool_id() -> egui::Id { + egui::Id::new("canvas-temp-tool") +} + +pub fn mods(m: egui::Modifiers, space: bool) -> Mods { + Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } +} + +/// Fit the view (View → Fit Artboard / Fit All / Actual Size). +pub fn fit(app: &mut DrawcraftApp, how: &str) { + let Some(rect) = app.canvas_rect else { + if let Some(v) = app.view_mut() { + v.fitted = false; + } + return; + }; + let Some(st) = app.session.active() else { return }; + let target = match how { + "view.fitAll" => st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)), + _ => st.doc.artboards.first().map(|a| a.rect), + }; + let Some(target) = target else { return }; + let v = app.view_mut().unwrap(); + v.center = target.center(); + v.fitted = true; + if how == "view.actualSize" { + v.zoom = 1.0; + } else { + let zx = (rect.width() as f64 - 60.0) / target.width().max(1.0); + let zy = (rect.height() as f64 - 60.0) / target.height().max(1.0); + v.zoom = zx.min(zy).clamp(0.0313, 640.0); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let full = ui.available_rect_before_wrap(); + if app.session.active().is_none() { + home(app, ui, full); + return; + } + let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; + app.canvas_rect = Some(rect); + let fitted = app.view().is_some_and(|v| v.fitted); + if !fitted { + fit(app, "view.fitArtboard"); + } + let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); + handle_input(app, ui, &resp, rect); + let v = *app.view().unwrap_or(&View::default()); + let xf = Xf { rect, zoom: v.zoom, center: v.center }; + let painter = ui.painter_at(rect); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + + // Pasteboard, artboard shadows and paper. + painter.rect_filled(rect, 0.0, t.pasteboard); + if app.ui.view.artboards && !app.ui.view.outline { + for ab in &doc.artboards { + let r = xf.rect_to_screen(ab.rect); + painter.rect_filled(r.translate(vec2(2.0, 2.0)).expand(1.0), 1.0, Color32::from_black_alpha(90)); + painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(110)); + if app.ui.view.transparency_grid { + checker(&painter, r); + } else { + painter.rect_filled(r, 0.0, Color32::WHITE); + } + } + } else if app.ui.view.outline { + for ab in &doc.artboards { + painter.rect_filled(xf.rect_to_screen(ab.rect), 0.0, Color32::WHITE); + } + } + if app.ui.view.grid { + grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions); + } + + // Artwork raster. + let ppp = ui.ctx().pixels_per_point(); + let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); + let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] }; + if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { + let t0 = now_ms(); + let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2()); + let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; + let img = app.canvas.renderer.render(&doc, w, h, view, &opts); + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + match &mut app.canvas.texture { + Some(tex) => tex.set(color, egui::TextureOptions::LINEAR), + None => app.canvas.texture = Some(ui.ctx().load_texture("canvas", color, egui::TextureOptions::LINEAR)), + } + app.canvas.key = Some(key); + app.perf.render_ms = now_ms() - t0; + } + if let Some(tex) = &app.canvas.texture { + painter.image(tex.id(), rect, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + // Artboard edges and names. + let active_ab = 0; + for (i, ab) in doc.artboards.iter().enumerate() { + let r = xf.rect_to_screen(ab.rect); + let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; + painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside); + if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 { + painter.text(r.left_top() - vec2(0.0, 4.0), egui::Align2::LEFT_BOTTOM, format!("{:02} - {}", i + 1, ab.name), egui::FontId::proportional(11.0), t.text_dim); + } + } + if app.ui.view.guides { + for g in &doc.guides { + let (a, b) = if g.vertical { + let x = xf.to_screen(Point::new(g.pos, 0.0)).x; + (pos2(x, rect.top()), pos2(x, rect.bottom())) + } else { + let y = xf.to_screen(Point::new(0.0, g.pos)).y; + (pos2(rect.left(), y), pos2(rect.right(), y)) + }; + painter.line_segment([a, b], Stroke::new(1.0, t.guide)); + } + } + + // Selection visuals and tool overlays. + if app.ui.view.edges { + hover_highlight(app, &painter, &xf); + selection_overlay(app, &painter, &xf); + } + let view_info = app.view_info(); + let overlays = app.session.overlays(view_info); + draw_overlays(&painter, &xf, &overlays, &t); + + if app.ui.view.rulers && app.ui.screen_mode < 3 { + rulers(ui, full, &xf, app.hover_doc, &t); + } + // Cursor. + if resp.hovered() { + let m = ui.input(|i| i.modifiers); + let space = ui.input(|i| i.key_down(egui::Key::Space)); + let cur = if space || app.session.tool_id() == "hand" { + if ui.input(|i| i.pointer.primary_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + } else if app.session.tool_id() == "zoom" { + if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn } + } else if let Some(p) = app.hover_doc { + let c = app.session.cursor(p, mods(m, space), view_info); + if matches!(c, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue) + && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) + { + pen_cursor_badge(ui, hp, c, &t); + } + cursor_icon(c) + } else { + egui::CursorIcon::Default + }; + ui.ctx().set_cursor_icon(cur); + } +} + +fn pen_cursor_badge(ui: &Ui, p: Pos2, c: Cursor, t: &Tokens) { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pen-cursor"))); + let r = egui::Rect::from_min_size(p + vec2(6.0, 4.0), vec2(16.0, 16.0)); + // Pen nib glyph (black with white outline, like a system cursor). + let tip = r.left_top(); + let nib = vec![tip, tip + vec2(11.0, 4.0), tip + vec2(13.0, 13.0), tip + vec2(4.0, 11.0)]; + painter.add(Shape::convex_polygon(nib, Color32::BLACK, Stroke::new(1.0, Color32::WHITE))); + let _ = t; + let badge = match c { + Cursor::PenClose => "o", + Cursor::PenContinue => "/", + Cursor::PenAdd => "+", + Cursor::PenDelete => "–", + _ => "", + }; + if !badge.is_empty() { + painter.text(r.right_bottom() + vec2(1.0, -2.0), egui::Align2::LEFT_BOTTOM, badge, egui::FontId::proportional(11.0), Color32::BLACK); + } +} + +fn cursor_icon(c: Cursor) -> egui::CursorIcon { + use egui::CursorIcon as C; + match c { + Cursor::Arrow => C::Default, + Cursor::ArrowHollow => C::Default, + Cursor::Move => C::Move, + Cursor::Crosshair => C::Crosshair, + Cursor::ResizeH => C::ResizeHorizontal, + Cursor::ResizeV => C::ResizeVertical, + Cursor::ResizeNwSe => C::ResizeNwSe, + Cursor::ResizeNeSw => C::ResizeNeSw, + Cursor::Rotate => C::Alias, + Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue => C::Crosshair, + Cursor::Text => C::Text, + Cursor::Hand => C::Grab, + Cursor::HandGrab => C::Grabbing, + Cursor::ZoomIn => C::ZoomIn, + Cursor::ZoomOut => C::ZoomOut, + Cursor::Eyedropper => C::Crosshair, + Cursor::NotAllowed => C::NotAllowed, + } +} + +fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { + let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta())); + let v = *app.view().unwrap_or(&View::default()); + let xf = Xf { rect, zoom: v.zoom, center: v.center }; + let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); + app.hover_doc = hover.map(|p| xf.to_doc(p)); + let view = app.view_info(); + let drag: Option = ui.data(|d| d.get_temp(drag_id())); + + // Zoom: pinch / Cmd-scroll / Alt-scroll around the pointer. Plain scroll pans. + if resp.hovered() { + let mut factor = zoom_delta as f64; + if m.alt && scroll.y != 0.0 { + factor *= (scroll.y as f64 * 0.01).exp(); + } + if (factor - 1.0).abs() > 1e-6 { + if let (Some(p), Some(vm)) = (hover, app.view_mut()) { + let before = xf.to_doc(p); + vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0); + let nx = Xf { rect, zoom: vm.zoom, center: vm.center }; + let after = nx.to_doc(p); + vm.center += before - after; + } + } else if (scroll.x != 0.0 || scroll.y != 0.0) + && !m.alt + && let Some(vm) = app.view_mut() + { + vm.center.x -= scroll.x as f64 / vm.zoom; + vm.center.y -= scroll.y as f64 / vm.zoom; + } + } + + let tool = app.session.tool_id(); + let pan_mode = space || tool == "hand"; + if pointer.primary_pressed() && resp.hovered() { + ui.ctx().memory_mut(|mem| mem.stop_text_input()); + app.ui.flyout = None; + let p = hover.unwrap_or(rect.center()); + let d = if pan_mode { + Drag::Pan { start: p, center: v.center } + } else if tool == "zoom" { + Drag::ZoomBox { start: p } + } else { + let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection"); + let mut kind = Drag::Tool; + if m.command && !selection_family { + ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string())); + app.select_tool("selection"); + kind = Drag::TempSelect; + } + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + kind + }; + ui.data_mut(|dd| dd.insert_temp(drag_id(), d)); + } else if let Some(d) = drag { + let p = pointer.interact_pos().unwrap_or(rect.center()); + if pointer.primary_down() { + match d { + Drag::Pan { start, center } => { + if let Some(vm) = app.view_mut() { + vm.center = center - (p - start).to_vec2_f64() / vm.zoom; + } + } + Drag::ZoomBox { .. } => {} + Drag::Tool | Drag::TempSelect => { + if pointer.delta() != egui::Vec2::ZERO { + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + } + } + } + } else { + ui.data_mut(|dd| dd.remove::(drag_id())); + match d { + Drag::ZoomBox { start } => { + let r = egui::Rect::from_two_pos(start, p); + if let Some(vm) = app.view_mut() { + if r.width() > 8.0 && r.height() > 8.0 { + let a = xf.to_doc(r.min); + let b = xf.to_doc(r.max); + vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); + vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0); + } else { + let before = xf.to_doc(p); + vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt); + let nx = Xf { rect, zoom: vm.zoom, center: vm.center }; + vm.center += before - nx.to_doc(p); + } + } + } + Drag::Tool | Drag::TempSelect => { + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + if d == Drag::TempSelect + && let Some(prev) = ui.data(|dd| dd.get_temp::(temp_tool_id())) + { + app.select_tool(&prev); + } + } + Drag::Pan { .. } => {} + } + } + } else if let Some(p) = hover { + if pointer.is_moving() { + let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + } + } + if resp.double_clicked() + && let Some(p) = hover + { + let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); + } + if drag.is_some() || pointer.is_moving() { + ui.ctx().request_repaint(); + } +} + +trait ToVec2F64 { + fn to_vec2_f64(self) -> drawcraft_geom::Vec2; +} +impl ToVec2F64 for egui::Vec2 { + fn to_vec2_f64(self) -> drawcraft_geom::Vec2 { + drawcraft_geom::Vec2::new(self.x as f64, self.y as f64) + } +} + +/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). +pub fn dispatch(app: &mut DrawcraftApp, ev: &PointerEvent, view: drawcraft_engine::ViewInfo) { + match app.session.pointer(ev, view) { + Ok(reqs) => { + for r in reqs { + match r { + drawcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p), + drawcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), + } + } + } + Err(e) => app.status(e.to_string()), + } +} + +fn checker(p: &egui::Painter, r: egui::Rect) { + p.rect_filled(r, 0.0, Color32::WHITE); + let s = 8.0; + let clip = p.with_clip_rect(r.intersect(p.clip_rect())); + let (nx, ny) = (((r.width() / s).ceil() as i32).min(400), ((r.height() / s).ceil() as i32).min(400)); + for y in 0..ny { + for x in 0..nx { + if (x + y) % 2 == 1 { + clip.rect_filled(egui::Rect::from_min_size(r.min + vec2(x as f32 * s, y as f32 * s), vec2(s, s)), 0.0, Color32::from_gray(204)); + } + } + } +} + +fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) { + let r = xf.rect; + let a = xf.to_doc(r.min); + let b = xf.to_doc(r.max); + let sub = spacing / subdiv.max(1) as f64; + let step = if sub * xf.zoom >= 6.0 { sub } else { spacing }; + if step * xf.zoom < 4.0 { + return; + } + let mut x = (a.x / step).floor() * step; + while x <= b.x { + let major = (x / spacing).round() * spacing == x || (x / spacing - (x / spacing).round()).abs() < 1e-6; + let sx = xf.to_screen(Point::new(x, 0.0)).x; + p.line_segment([pos2(sx, r.top()), pos2(sx, r.bottom())], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 }))); + x += step; + } + let mut y = (a.y / step).floor() * step; + while y <= b.y { + let major = (y / spacing - (y / spacing).round()).abs() < 1e-6; + let sy = xf.to_screen(Point::new(0.0, y)).y; + p.line_segment([pos2(r.left(), sy), pos2(r.right(), sy)], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 }))); + y += step; + } +} + +fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) { + let p = ui.painter(); + let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); + let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); + let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER)); + for r in [top, left, corner] { + p.rect_filled(r, 0.0, t.ruler); + } + p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border)); + p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); + // Crosshair in the origin box. + p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick)); + p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick)); + // Pick a label step that gives ≥ 50 px between labels. + let steps = [1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; + let step = steps.iter().copied().find(|s| s * xf.zoom >= 50.0).unwrap_or(10000.0); + let minor = step / 10.0; + let font = egui::FontId::proportional(9.5); + let a = xf.to_doc(top.left_top()); + let b = xf.to_doc(top.right_top()); + let clip_top = p.with_clip_rect(top); + let mut x = (a.x / minor).floor() * minor; + while x <= b.x { + let sx = xf.to_screen(Point::new(x, 0.0)).x; + let is_major = ((x / step).round() * step - x).abs() < minor * 0.01; + let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01; + let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 }; + clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); + if is_major { + clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick); + } + x += minor; + } + let a = xf.to_doc(left.left_top()); + let b = xf.to_doc(left.left_bottom()); + let clip_left = p.with_clip_rect(left); + let mut y = (a.y / minor).floor() * minor; + while y <= b.y { + let sy = xf.to_screen(Point::new(0.0, y)).y; + let is_major = ((y / step).round() * step - y).abs() < minor * 0.01; + let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01; + let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 }; + clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); + if is_major { + // Vertical labels read top-to-bottom, one digit per line like Illustrator. + let s = format!("{}", y.round() as i64); + for (k, ch) in s.chars().enumerate() { + clip_left.text(pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, ch.to_string(), font.clone(), t.ruler_tick); + } + } + y += minor; + } + if let Some(h) = hover { + let s = xf.to_screen(h); + clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text)); + clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text)); + } +} + +fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec> { + // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). + let mut out: Vec> = vec![]; + let a = xf.affine(); + let mut t = bp.clone(); + t.apply_affine(a); + let mut cur: Vec = vec![]; + kurbo_flatten(&t, 0.25, &mut |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + cur.push(pos2(p.x as f32, p.y as f32)); + } + PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + out +} + +fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) { + for line in to_screen_path(bp, xf) { + p.add(Shape::line(line, s)); + } +} + +fn c32(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) +} + +/// Outline of a node for highlighting (paths, compound children, text/image bounds). +fn node_outline(n: &Node) -> BezPath { + let mut bp = BezPath::new(); + n.walk(&mut |c| match &c.kind { + NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), + NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + if let Some(b) = c.geometric_bounds() { + bp.extend(drawcraft_geom::shapes::rectangle(b).to_bezpath()); + } + } + _ => {} + }); + bp +} + +fn hover_highlight(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(h) = app.hover_doc else { return }; + if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { + return; + } + let Some(st) = app.session.active() else { return }; + let opt = drawcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false }; + let Some(hit) = drawcraft_doc::hit::hit_test(&st.doc, h, opt) else { return }; + let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; + if st.selection.contains(id) { + return; + } + if let Some(n) = st.doc.node(id) { + let color = c32(st.doc.layer_color(id)); + stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color)); + } +} + +fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { + let r = egui::Rect::from_center_size(c, vec2(size, size)); + if filled { + p.rect_filled(r, 0.0, color); + } else { + p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + } +} + +fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let tool = app.session.tool_id(); + let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature"); + for id in &st.selection.objects { + let Some(n) = st.doc.node(*id) else { continue }; + let color = c32(st.doc.layer_color(*id)); + let partial = st.selection.partial(*id); + // Path outlines. + stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color)); + // Anchors (and handles for selected anchors in direct mode). + n.walk(&mut |c| { + let NodeKind::Path { path, .. } = &c.kind else { return }; + for (si, ai, a) in path.anchors() { + let sel = match partial { + Some(set) => set.contains(&(si, ai)), + None => !direct || c.id == *id, + }; + let sp = xf.to_screen(a.p); + if sel && (direct || partial.is_some()) { + for h in [a.h_in, a.h_out] { + if h.distance(a.p) > 1e-6 { + let hp = xf.to_screen(h); + p.line_segment([sp, hp], Stroke::new(1.0, color)); + p.circle_filled(hp, 2.8, color); + } + } + } + anchor_square(p, sp, color, sel, if partial.is_some() || direct { 6.0 } else { 4.5 }); + } + }); + // Text: baseline marker. + if let NodeKind::Text(tx) = &n.kind { + let o = xf.to_screen(tx.xf * Point::ZERO); + let b = n.geometric_bounds().unwrap_or_default(); + let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y)); + p.line_segment([o, e], Stroke::new(1.0, color)); + p.circle_filled(o, 2.5, color); + } + } + // Bounding box with handles (Selection tool). + if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { + let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return }; + let color = c32(st.doc.layer_color(st.selection.objects[0])); + let r = xf.rect_to_screen(b); + p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + for h in drawcraft_tools::bbox::Handle::ALL { + let c = xf.to_screen(h.pos(b)); + let hr = egui::Rect::from_center_size(c, vec2(7.0, 7.0)); + p.rect_filled(hr, 0.0, Color32::WHITE); + p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + } + // Centre point. + let c = r.center(); + p.rect_filled(egui::Rect::from_center_size(c, vec2(3.0, 3.0)), 0.0, color); + // Live corner widgets on single live rectangles. + if st.selection.len() == 1 + && let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = st.doc.node(st.selection.objects[0]).map(|n| &n.kind) + && r.width() > 40.0 + && r.height() > 40.0 + { + let inset = (radii[0].max(20.0 / xf.zoom)).min(w.min(*h) / 2.0); + for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] { + let sp = xf.to_screen(*lxf * Point::new(cx, cy)); + p.circle_stroke(sp, 3.5, Stroke::new(1.0, color)); + p.circle_filled(sp, 1.3, color); + } + } + } +} + +fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { + for o in overlays { + match o { + Overlay::Marquee(r) => { + let sr = xf.rect_to_screen(*r); + let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()]; + for w in pts.windows(2) { + p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0)); + } + } + Overlay::Path { path, color, width, dashed } => { + let s = Stroke::new(*width, c32(*color)); + if *dashed { + for line in to_screen_path(path, xf) { + p.extend(Shape::dashed_line(&line, s, 4.0, 3.0)); + } + } else { + stroke_path(p, path, xf, s); + } + } + Overlay::Line { a, b, color, dashed } => { + let s = Stroke::new(1.0, c32(*color)); + let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); + if *dashed { + p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0)); + } else { + p.line_segment([a, b], s); + } + } + Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size), + Overlay::Handle { p: pt, color } => { + p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color)); + } + Overlay::Label { p: pt, text, color } => { + let sp = xf.to_screen(*pt) + vec2(8.0, -14.0); + p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color)); + } + Overlay::Measure { p: pt, text } => { + let sp = xf.to_screen(*pt) + vec2(14.0, 14.0); + let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0); + let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0)); + p.rect_filled(r, CornerRadius::same(3), t.measure_bg); + p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); + } + } + } +} + +/// The Home screen shown when no document is open. +fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, 0.0, t.panel_darker); + let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min))); + let ui = &mut child; + ui.label(egui::RichText::new("Welcome to DrawCraft").font(theme::semibold(26.0)).color(t.text)); + ui.add_space(4.0); + ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim)); + ui.add_space(22.0); + ui.horizontal(|ui| { + if widgets::primary_button(ui, "New file").clicked() { + app.run("file.newDialog", json!({})).ok(); + } + ui.add_space(8.0); + if widgets::secondary_button(ui, "Open").clicked() { + app.run("file.open", json!({})).ok(); + } + }); + ui.add_space(28.0); + ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text)); + ui.add_space(10.0); + let presets: [(&str, &str, f64, f64); 6] = [ + ("Letter", "612 × 792 pt", 612.0, 792.0), + ("A4", "595.28 × 841.89 pt", 595.28, 841.89), + ("Web 1920", "1920 × 1080 px", 1920.0, 1080.0), + ("Mobile", "390 × 844 px", 390.0, 844.0), + ("Postcard", "288 × 432 pt", 432.0, 288.0), + ("Square", "1080 × 1080 px", 1080.0, 1080.0), + ]; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 14.0); + for (name, size, w, h) in presets { + let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click()); + ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); + let s = (70.0 / w.max(h)) as f32; + let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s)); + ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80)); + ui.painter().rect_filled(pr, 0.0, Color32::WHITE); + ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text); + ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim); + if resp.clicked() { + app.run("file.new", json!({"width": w, "height": h, "units": if size.ends_with("px") { "Pixels" } else { "Points" }})).ok(); + } + } + }); +} + + +fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { + drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs new file mode 100644 index 00000000..8d0b3898 --- /dev/null +++ b/crates/ui-egui/src/chrome.rs @@ -0,0 +1,285 @@ +//! Window chrome: application bar (menus), Control bar, document tabs, status bar. + +use drawcraft_color::Paint; +use drawcraft_doc::NodeKind; +use egui::{Color32, CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use crate::state::{ZOOM_STOPS, zoom_label}; +use crate::theme::{self, Tokens}; +use crate::widgets::{self, paint_chip}; +use crate::{DrawcraftApp, icons, menus}; + +pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let left = if app.integrated_titlebar { 78 } else { 8 }; + egui::Panel::top("app_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 })).show(ui, |ui| { + ui.horizontal_centered(|ui| { + // Brand mark: a small rounded square with "Dc". + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_rgb(0x2b, 0x1d, 0x0c)); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.2, Color32::from_rgb(0xff, 0x9a, 0x00)), StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "Dc", theme::semibold(11.5), Color32::from_rgb(0xff, 0x9a, 0x00)); + ui.add_space(4.0); + if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() { + app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); + } + ui.add_space(2.0); + menus::menu_bar(app, ui); + let full = ui.max_rect(); + let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom()); + let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); + (|ui: &mut Ui| { + let ws = egui::Frame::NONE.fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::symmetric(8, 3)); + ws.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Essentials").size(12.0).color(t.text)); + icons::icon(ui, "chevron-down", 12.0, t.text_dim); + }); + }); + ui.add_space(6.0); + // Search box → command palette. + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, CornerRadius::same(12), t.input); + ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); + icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); + ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); + if resp.clicked() { + app.ui.palette_open = true; + app.ui.palette_query.clear(); + } + })(&mut rui); + }); + }); +} + +fn chip_button(ui: &mut Ui, paint: &Paint, stroke_style: bool, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click()); + let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0)); + paint_chip(ui, chip, paint); + if stroke_style { + ui.painter().rect_filled(chip.shrink(5.0), 0.0, t.panel); + } + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); + icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim); + resp.on_hover_text(tip) +} + +/// The Control bar (Window → Control), context-sensitive like Illustrator's. +pub fn control_bar(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("control_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + ui.horizontal_centered(|ui| { + // Gripper + let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); + for i in 0..5 { + ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); + } + let Some(st) = app.session.active() else { + ui.label(egui::RichText::new("No Document").color(t.text_dim)); + return; + }; + let sel = st.selection.objects.clone(); + let units = st.doc.units; + let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); + let anchor_mode = !st.selection.anchors.is_empty(); + let label = match (&first, sel.len()) { + (None, _) => "No Selection".to_string(), + (_, n) if n > 1 => "Mixed Objects".to_string(), + (Some(_), _) if anchor_mode => "Anchor Point".to_string(), + (Some(n), _) => match &n.kind { + NodeKind::Text(_) => "Type".into(), + NodeKind::Image(_) => "Embedded".into(), + NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(), + _ => n.kind_label().to_string(), + }, + }; + ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + ui.add_space(6.0); + let (fill, stroke, weight, opacity) = match &first { + Some(n) => (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity), + None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0), + }; + if chip_button(ui, &fill, false, "Fill").clicked() { + app.run("paint.toggleActive", json!({})).ok(); + app.session.fill_active = true; + app.ui.open_panel = Some("swatches".into()); + } + if chip_button(ui, &stroke, true, "Stroke").clicked() { + app.session.fill_active = false; + app.ui.open_panel = Some("swatches".into()); + } + if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("stroke".into()); + } + if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) { + app.run("stroke.set", json!({"weight": w})).ok(); + } + ui.add_space(4.0); + ui.separator(); + if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() { + app.ui.open_panel = Some("transparency".into()); + } + if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0) + && !sel.is_empty() + { + app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); + } + ui.separator(); + if sel.is_empty() { + if widgets::flat_button(ui, "Document Setup", 112.0).clicked() { + app.run("file.documentSetup", json!({})).ok(); + } + if widgets::flat_button(ui, "Preferences", 90.0).clicked() { + app.run("edit.preferences", json!({})).ok(); + } + return; + } + // Align buttons. + for (icon, tip, p) in [ + ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), + ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), + ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), + ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), + ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), + ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), + ] { + if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() { + let mut p = p; + if sel.len() == 1 { + p["to"] = json!("artboard"); + } + app.run("object.align", p).ok(); + } + } + ui.separator(); + // Transform fields. + let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false)); + if let Some(b) = b { + for (k, lbl, v) in [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] { + ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); + if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) { + app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); + } + } + } + }); + }); +} + +/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". +pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); + let mut x = strip.left(); + let mut activate = None; + let mut close = None; + let active = app.session.active_index(); + for (i, d) in app.session.documents().iter().enumerate() { + let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); + let mode = if d.doc.color_mode == drawcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; + let vm = if app.ui.view.outline { "Outline" } else { "Preview" }; + let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom)); + let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(12.0), if Some(i) == active { t.text } else { t.text_dim }); + let w = galley.size().x + 44.0; + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height())); + let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click()); + if Some(i) == active { + ui.painter().rect_filled(r, 0.0, t.panel); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5)); + } + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); + ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + let xr = egui::Rect::from_center_size(egui::pos2(r.right() - 15.0, r.center().y), vec2(14.0, 14.0)); + let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click()); + if xresp.hovered() || Some(i) == active || resp.hovered() { + icons::paint(ui, "x", xr.shrink(1.5), if xresp.hovered() { t.text } else { t.text_dim }); + } + if xresp.clicked() { + close = Some(i); + } else if resp.clicked() { + activate = Some(i); + } + x += w; + } + if let Some(i) = close { + app.session.close_document(i); + app.views.remove(i.min(app.views.len().saturating_sub(1))); + app.sync_views(); + } else if let Some(i) = activate { + app.session.set_active(i); + } + // Isolation mode breadcrumb bar. + if let Some(st) = app.session.active() + && let Some(iso) = st.isolation + { + let crumbs: Vec = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.panel); + let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); + let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click()); + icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); + ui.painter().text(bar.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, crumbs.join(" › "), egui::FontId::proportional(12.0), t.text); + if bresp.clicked() { + app.run("object.exitIsolation", json!({})).ok(); + } + } +} + +pub fn status_bar(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("status_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + ui.horizontal_centered(|ui| { + let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); + egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(ui, |ui| { + for z in ZOOM_STOPS.iter().rev() { + if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() { + app.run("view.setZoom", json!({"zoom": z})).ok(); + } + } + ui.separator(); + if ui.selectable_label(false, "Fit on Screen").clicked() { + app.run("view.fitArtboard", json!({})).ok(); + } + if ui.selectable_label(false, "Fit All").clicked() { + app.run("view.fitAll", json!({})).ok(); + } + }); + let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); + egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(ui, |ui| { + for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] { + if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked() + && let Some(v) = app.view_mut() + { + v.rotation = a; + } + } + }); + ui.separator(); + let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); + for icon in ["chevrons-left", "chevron-left"] { + widgets::icon_button(ui, icon, "", false, 18.0); + } + ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5)); + for icon in ["chevron-right", "chevrons-right"] { + widgets::icon_button(ui, icon, "", false, 18.0); + } + ui.separator(); + let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or(""); + ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(format!("render {:.1} ms · ui {:.1} ms · {:.0} fps", app.perf.render_ms, app.perf.frame_ms, app.perf.fps)).size(10.5).color(t.text_disabled)); + if let Some(p) = app.hover_doc { + ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim)); + } + if !app.ui.status.is_empty() { + ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text)); + } + }); + }); + }); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 00000000..3d7c1ba8 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,264 @@ +//! Programmatic control of the running app (agents, tests, MCP). +//! +//! Methods (JSON-lines over the host's transport): +//! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command +//! - `engine.commands`: engine + UI commands with enablement +//! - `document.inspect`: document summary; `ui.inspect`: UI state +//! - `ui.menu.list`: flattened menu tree +//! - `ui.tool.select {tool}`, `ui.tool.list` +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: +//! drive the active tool through the same path as the mouse +//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input +//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` +//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` +//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` +//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG) +//! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit` + +use std::sync::mpsc::Sender; + +use drawcraft_tools::{Mods, PointerEvent, PointerKind}; +use serde_json::{Value, json}; + +use crate::DrawcraftApp; +use crate::canvas::Xf; + +pub type ControlResponse = Value; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + (Self { method: method.into(), params, reply: tx }, rx) + } +} + +pub enum Outcome { + Done(Value), + Screenshot { path: Option }, +} + +fn ok(v: Value) -> Outcome { + Outcome::Done(json!({"ok": true, "result": v})) +} +fn err(e: impl std::fmt::Display) -> Outcome { + Outcome::Done(json!({"ok": false, "error": e.to_string()})) +} +fn wrap(r: Result) -> Outcome { + match r { + Ok(v) => ok(v), + Err(e) => err(e), + } +} + +pub fn all_commands(app: &DrawcraftApp) -> Value { + let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + for (id, label, sc, params) in crate::menus::UI_COMMANDS { + v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": crate::menus::enabled(app, id), "ui": true})); + } + Value::Array(v) +} + +pub fn inspect(app: &DrawcraftApp, ctx: &egui::Context) -> Value { + let r = ctx.content_rect(); + json!({ + "tool": app.session.tool_id(), + "toolOptions": app.session.tool_options(), + "ui": serde_json::to_value(&app.ui).unwrap_or_default(), + "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), + "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "window": [r.width(), r.height()], + "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::>(), + "activeDocument": app.session.active_index(), + "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, + }) +} + +fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" => Some(egui::Key::Delete), + "backspace" => Some(egui::Key::Backspace), + "left" => Some(egui::Key::ArrowLeft), + "right" => Some(egui::Key::ArrowRight), + "up" => Some(egui::Key::ArrowUp), + "down" => Some(egui::Key::ArrowDown), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + _ => None, + }) +} + +pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + match req.method.as_str() { + "engine.execute" | "ui.menu.invoke" | "command" => { + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().unwrap_or(json!({})); + let params = if params.is_null() { json!({}) } else { params }; + wrap(app.run(id, params)) + } + "engine.commands" => ok(all_commands(app)), + "document.inspect" => wrap(app.run("document.inspect", json!({}))), + "ui.inspect" => ok(inspect(app, ctx)), + "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), + "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), + "ui.tool.list" => ok(serde_json::to_value(drawcraft_tools::TOOL_GROUPS).unwrap_or_default()), + "ui.pointer" => { + let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let view = app.view_info(); + let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf { rect, zoom: v.zoom, center: v.center }); + for e in events { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return err(format!("unknown pointer kind `{other}`")), + }; + let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let pos = if e.get("space").and_then(Value::as_str) == Some("screen") { + match xf { + Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)), + None => return err("no canvas yet"), + } + } else { + drawcraft_geom::Point::new(x, y) + }; + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: 1.0 }, view); + } + ctx.request_repaint(); + wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()}))) + } + "ui.key" => { + let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + let m = egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }; + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + if let Some(t) = s("text") { + app.synthetic.push(egui::Event::Text(t.to_string())); + } + ctx.request_repaint(); + ok(Value::Null) + } + "ui.text" => { + app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); + ctx.request_repaint(); + ok(Value::Null) + } + "ui.set" => { + let mut r = Ok(Value::Null); + if let Some(b) = s("brightness") { + r = app.run("window.brightness", json!({"brightness": b})); + } + if let Some(pn) = s("panel") { + r = app.run("window.panel", json!({"panel": pn})); + } + for (k, flag) in [("rulers", "view.rulers"), ("outline", "view.outline"), ("grid", "view.grid"), ("smartGuides", "view.smartGuides"), ("boundingBox", "view.boundingBox")] { + if let Some(want) = p.get(k).and_then(Value::as_bool) { + let cur = match k { + "rulers" => app.ui.view.rulers, + "outline" => app.ui.view.outline, + "grid" => app.ui.view.grid, + "smartGuides" => app.ui.view.smart_guides, + _ => app.ui.view.bounding_box, + }; + if cur != want { + r = app.run(flag, json!({})); + } + } + } + if let Some(v) = p.get("controlBar").and_then(Value::as_bool) { + app.ui.control_bar = v; + } + if p.get("closePanel").is_some() { + app.ui.open_panel = None; + } + wrap(r) + } + "ui.dialog.set" => match app.ui.dialog.as_mut() { + Some(d) => { + let Some(f) = s("field") else { return err("missing `field`") }; + d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + ok(serde_json::to_value(&*d).unwrap_or_default()) + } + None => err("no dialog open"), + }, + "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), + "ui.dialog.cancel" => { + app.ui.dialog = None; + ok(Value::Null) + } + "ui.resize" => { + let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32; + let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ok(Value::Null) + } + "ui.focus" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ok(Value::Null) + } + "ui.screenshot" => { + if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + ctx.request_repaint(); + Outcome::Screenshot { path: s("path").map(str::to_string) } + } + "ui.render" => { + let Some(st) = app.session.active() else { return err("no document") }; + let doc = st.doc.clone(); + let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") }; + let img = app.canvas.renderer.render_region(&doc, r, p.get("scale").and_then(Value::as_f64).unwrap_or(1.0), true); + let png = img.to_png(); + match s("path") { + Some(path) => match app.services.write.as_mut() { + Some(w) => wrap(w(path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height}))), + None => err("no writer"), + }, + None => ok(json!({"width": img.width, "height": img.height, "pngBase64": drawcraft_format::base64_encode(&png)})), + } + } + "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), + "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), + "app.export" => { + let f = s("format").unwrap_or("png"); + wrap(crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))) + } + "app.quit" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Value::Null) + } + other => err(format!("unknown method `{other}`")), + } +} + +pub fn save_screenshot(app: &mut DrawcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value { + let [w, h] = image.size; + let Some(path) = path else { + return json!({"ok": true, "result": {"width": w, "height": h}}); + }; + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + let img = drawcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba }; + let png = img.to_png(); + match app.services.write.as_mut() { + Some(wr) => match wr(path, &png) { + Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e}), + }, + None => json!({"ok": false, "error": "no writer configured"}), + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 00000000..f77d0360 --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,265 @@ +//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them +//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`. + +use serde_json::{Value, json}; + +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{DrawcraftApp, widgets}; + +/// A click with a shape tool opens its size dialog. +pub fn open_tool_dialog(app: &mut DrawcraftApp, kind: &str, p: Value) { + let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let d = match kind { + "rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})), + "roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})), + "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), + "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), + "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), + _ => return, + }; + app.ui.dialog = Some(d); +} + +fn title(kind: &str) -> &'static str { + match kind { + "newDocument" => "New Document", + "rectangle" => "Rectangle", + "roundedRectangle" => "Rounded Rectangle", + "ellipse" => "Ellipse", + "polygon" => "Polygon", + "star" => "Star", + "lineSegment" => "Line Segment Tool Options", + "move" => "Move", + "rotate" => "Rotate", + "scale" => "Scale", + "reflect" => "Reflect", + "shear" => "Shear", + "average" => "Average", + "offsetPath" => "Offset Path", + "simplify" => "Simplify", + "splitIntoGrid" => "Split Into Grid", + "documentSetup" => "Document Setup", + "preferences" => "Preferences", + "allTools" => "All Tools", + _ => "Dialog", + } +} + +/// Apply the open dialog (OK). +pub fn confirm(app: &mut DrawcraftApp) -> Result { + let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; + let copy = d.bool("copy"); + let r = match d.kind.as_str() { + "newDocument" => { + let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()})); + if r.is_ok() { + app.ui.dialog = None; + } + return r; + } + "rectangle" | "roundedRectangle" => app.run("shape.rectangle", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)})), + "ellipse" => app.run("shape.ellipse", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})), + "polygon" => app.run("shape.polygon", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})), + "star" => app.run("shape.star", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64})), + "lineSegment" => { + let (x, y, l, a) = (d.f64("x", 0.0), d.f64("y", 0.0), d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); + app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) + } + "move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})), + "rotate" => app.run("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})), + "scale" => { + let sx = d.f64("sx", 100.0); + let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; + app.run("object.scale", json!({"sx": sx, "sy": sy, "copy": copy})) + } + "reflect" => app.run("object.reflect", json!({"axis": d.str("axis"), "copy": copy})), + "shear" => app.run("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})), + "average" => app.run("path.average", json!({"axis": d.str("axis")})), + "offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})), + "simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})), + "splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})), + "documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})), + "preferences" => { + app.session.prefs.keyboard_increment = d.f64("keyboardIncrement", 1.0).max(0.001); + app.session.prefs.scale_strokes = d.bool("scaleStrokes"); + Ok(Value::Null) + } + _ => Ok(Value::Null), + }; + app.ui.dialog = None; + r +} + +fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(label).color(t.text_dim)); + let mut s = d.str(key); + let r = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(egui::CornerRadius::same(3)).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); + if r.inner.changed() { + d.fields.insert(key.into(), Value::String(s)); + } + ui.end_row(); +} + +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let mut b = d.bool(key); + if ui.checkbox(&mut b, label).changed() { + d.fields.insert(key.into(), Value::Bool(b)); + } +} + +pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { + if app.ui.about { + let mut open = true; + egui::Window::new("About DrawCraft").collapsible(false).resizable(false).open(&mut open).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0))); + ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); + ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft."); + }); + app.ui.about = open; + } + let Some(mut d) = app.ui.dialog.clone() else { return }; + let t = Tokens::get(ctx); + let mut ok = false; + let mut cancel = false; + egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { + ui.painter().rect_filled(ctx.content_rect(), 0.0, egui::Color32::from_black_alpha(90)); + ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); + }); + egui::Window::new(title(&d.kind)) + .id(egui::Id::new("dialog")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0]) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) + .show(ctx, |ui| { + ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 }); + ui.label(egui::RichText::new(title(&d.kind)).font(theme::semibold(16.0)).color(t.text)); + ui.add_space(12.0); + match d.kind.as_str() { + "newDocument" => new_document(ui, &mut d), + "allTools" => { + for g in drawcraft_tools::TOOL_GROUPS { + ui.horizontal_wrapped(|ui| { + for tool in g.iter() { + if ui.button(tool.label.trim_end_matches(" Tool")).clicked() { + app.select_tool(tool.id); + cancel = true; + } + } + }); + } + } + _ => { + egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in keys { + if k == "x" || k == "y" || v.is_boolean() { + continue; + } + field(ui, &mut d, &k, &humanize(&k)); + } + }); + if matches!(d.kind.as_str(), "move" | "rotate" | "scale" | "reflect" | "shear") { + ui.add_space(6.0); + if d.kind == "scale" { + check(ui, &mut d, "uniform", "Uniform"); + } + check(ui, &mut d, "copy", "Copy (make a transformed copy)"); + } + if d.kind == "preferences" { + check(ui, &mut d, "scaleStrokes", "Scale Strokes & Effects"); + } + } + } + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = if d.kind == "newDocument" { "Create" } else { "OK" }; + if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() { + ok = true; + } + ui.add_space(8.0); + if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() { + cancel = true; + } + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" { + ok = true; + } + app.ui.dialog = Some(d); + if cancel { + app.ui.dialog = None; + } else if ok && let Err(e) = confirm(app) { + app.status(e); + } +} + +fn humanize(k: &str) -> String { + let mut s = String::new(); + for (i, c) in k.chars().enumerate() { + if i == 0 { + s.extend(c.to_uppercase()); + } else if c.is_uppercase() { + s.push(' '); + s.push(c); + } else { + s.push(c); + } + } + match s.as_str() { + "Dx" => "Horizontal".into(), + "Dy" => "Vertical".into(), + "Sx" => "Horizontal %".into(), + "Sy" => "Vertical %".into(), + "Radius1" => "Radius 1".into(), + "Radius2" => "Radius 2".into(), + _ => format!("{s}:"), + } +} + +fn new_document(ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let presets: [(&str, &str, &str, &str); 8] = [ + ("Letter", "612 pt", "792 pt", "Points"), + ("Legal", "612 pt", "1008 pt", "Points"), + ("Tabloid", "792 pt", "1224 pt", "Points"), + ("A4", "595.28 pt", "841.89 pt", "Points"), + ("A3", "841.89 pt", "1190.55 pt", "Points"), + ("Web 1920", "1920 px", "1080 px", "Pixels"), + ("iPhone", "390 px", "844 px", "Pixels"), + ("Square Post", "1080 px", "1080 px", "Pixels"), + ]; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(300.0); + ui.label(egui::RichText::new("Presets").color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for (name, w, h, u) in presets { + let sel = d.str("preset") == name; + if ui.selectable_label(sel, name).clicked() { + d.fields.insert("preset".into(), json!(name)); + d.fields.insert("width".into(), json!(w)); + d.fields.insert("height".into(), json!(h)); + d.fields.insert("units".into(), json!(u)); + } + } + }); + }); + ui.separator(); + ui.vertical(|ui| { + ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); + egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + field(ui, d, "name", "Name:"); + field(ui, d, "width", "Width:"); + field(ui, d, "height", "Height:"); + field(ui, d, "artboards", "Artboards:"); + field(ui, d, "colorMode", "Color Mode:"); + }); + }); + }); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs new file mode 100644 index 00000000..7130bde6 --- /dev/null +++ b/crates/ui-egui/src/dock.rs @@ -0,0 +1,93 @@ +//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column +//! whose panels pop out to the left. + +use egui::{CornerRadius, Sense, Stroke, Ui, vec2}; + +use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS}; +use crate::theme::{self, Tokens}; +use crate::{DrawcraftApp, icons, panels, widgets}; + +const ICON_COL: f32 = 38.0; + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + // Collapsed icon column (rightmost). + egui::Panel::right("icon_column").resizable(false).exact_size(ICON_COL).frame(egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::symmetric(3, 6)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { + if gi > 0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); + } + for id in group.iter() { + let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; + let open = app.ui.open_panel.as_deref() == Some(*id); + if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } + } + } + }); + // Main tabbed group. + egui::Panel::right("dock").resizable(true).default_size(286.0).size_range(230.0..=520.0).frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + // Tab strip. + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.panel_darker); + let mut x = strip.left(); + for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] { + let active = app.ui.dock_tab == tab; + let galley = ui.painter().layout_no_wrap(label.to_string(), if active { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }, if active { t.text } else { t.text_dim }); + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height())); + let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click()); + if active { + ui.painter().rect_filled(r, 0.0, t.panel); + } + ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + if resp.clicked() { + app.ui.dock_tab = tab; + } + x = r.right(); + } + icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim); + egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { + DockTab::Properties => { + egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); + } + DockTab::Layers => panels::layers::show(app, ui), + DockTab::Libraries => panels::libraries(app, ui), + }); + }); +} + +/// An icon panel popped out next to the icon column. +pub fn floating_panel(app: &mut DrawcraftApp, ctx: &egui::Context) { + let Some(id) = app.ui.open_panel.clone() else { return }; + let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return }; + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let x = screen.right() - ICON_COL - 286.0 - 262.0; + let mut open = true; + egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, 110.0)).show(ctx, |ui| { + egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { + ui.set_width(256.0); + let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); + ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); + let tab = egui::Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0)); + ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); + ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, *label, theme::semibold(12.0), t.text); + let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); + let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click()); + icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim }); + if cr.clicked() { + open = false; + } + egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(236.0); + panels::show_icon_panel(app, ui, &id); + }); + }); + }); + if !open { + app.ui.open_panel = None; + } +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs new file mode 100644 index 00000000..f48ba503 --- /dev/null +++ b/crates/ui-egui/src/icon_data.rs @@ -0,0 +1,171 @@ +//! Generated icon table (`assets/icons`). Lucide icons are ISC-licensed; `dc-*` icons are DrawCraft's own. +//! Regenerate when icons are added: see crates/ui-egui/README.md. + +pub static ICONS: &[(&str, &[u8])] = &[ + ("align-center-horizontal", include_bytes!("../../../assets/icons/align-center-horizontal.svg")), + ("align-center-vertical", include_bytes!("../../../assets/icons/align-center-vertical.svg")), + ("align-end-horizontal", include_bytes!("../../../assets/icons/align-end-horizontal.svg")), + ("align-end-vertical", include_bytes!("../../../assets/icons/align-end-vertical.svg")), + ("align-horizontal-justify-center", include_bytes!("../../../assets/icons/align-horizontal-justify-center.svg")), + ("align-horizontal-justify-end", include_bytes!("../../../assets/icons/align-horizontal-justify-end.svg")), + ("align-horizontal-justify-start", include_bytes!("../../../assets/icons/align-horizontal-justify-start.svg")), + ("align-start-horizontal", include_bytes!("../../../assets/icons/align-start-horizontal.svg")), + ("align-start-vertical", include_bytes!("../../../assets/icons/align-start-vertical.svg")), + ("align-vertical-justify-center", include_bytes!("../../../assets/icons/align-vertical-justify-center.svg")), + ("align-vertical-justify-end", include_bytes!("../../../assets/icons/align-vertical-justify-end.svg")), + ("align-vertical-justify-start", include_bytes!("../../../assets/icons/align-vertical-justify-start.svg")), + ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), + ("arrow-up-down", include_bytes!("../../../assets/icons/arrow-up-down.svg")), + ("blend", include_bytes!("../../../assets/icons/blend.svg")), + ("brush", include_bytes!("../../../assets/icons/brush.svg")), + ("chart-column", include_bytes!("../../../assets/icons/chart-column.svg")), + ("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")), + ("chevron-left", include_bytes!("../../../assets/icons/chevron-left.svg")), + ("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")), + ("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")), + ("chevrons-right", include_bytes!("../../../assets/icons/chevrons-right.svg")), + ("circle", include_bytes!("../../../assets/icons/circle.svg")), + ("clipboard-paste", include_bytes!("../../../assets/icons/clipboard-paste.svg")), + ("cloud", include_bytes!("../../../assets/icons/cloud.svg")), + ("combine", include_bytes!("../../../assets/icons/combine.svg")), + ("copy", include_bytes!("../../../assets/icons/copy.svg")), + ("dc-align", include_bytes!("../../../assets/icons/dc-align.svg")), + ("dc-anchor", include_bytes!("../../../assets/icons/dc-anchor.svg")), + ("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")), + ("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")), + ("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")), + ("dc-blend", include_bytes!("../../../assets/icons/dc-blend.svg")), + ("dc-bloat", include_bytes!("../../../assets/icons/dc-bloat.svg")), + ("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")), + ("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")), + ("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")), + ("dc-draw-behind", include_bytes!("../../../assets/icons/dc-draw-behind.svg")), + ("dc-draw-inside", include_bytes!("../../../assets/icons/dc-draw-inside.svg")), + ("dc-draw-normal", include_bytes!("../../../assets/icons/dc-draw-normal.svg")), + ("dc-ellipse", include_bytes!("../../../assets/icons/dc-ellipse.svg")), + ("dc-fill-none", include_bytes!("../../../assets/icons/dc-fill-none.svg")), + ("dc-flare", include_bytes!("../../../assets/icons/dc-flare.svg")), + ("dc-free-transform", include_bytes!("../../../assets/icons/dc-free-transform.svg")), + ("dc-gradient", include_bytes!("../../../assets/icons/dc-gradient.svg")), + ("dc-graphic-styles", include_bytes!("../../../assets/icons/dc-graphic-styles.svg")), + ("dc-group-select", include_bytes!("../../../assets/icons/dc-group-select.svg")), + ("dc-join", include_bytes!("../../../assets/icons/dc-join.svg")), + ("dc-knife", include_bytes!("../../../assets/icons/dc-knife.svg")), + ("dc-line", include_bytes!("../../../assets/icons/dc-line.svg")), + ("dc-live-bucket", include_bytes!("../../../assets/icons/dc-live-bucket.svg")), + ("dc-live-select", include_bytes!("../../../assets/icons/dc-live-select.svg")), + ("dc-measure", include_bytes!("../../../assets/icons/dc-measure.svg")), + ("dc-mesh", include_bytes!("../../../assets/icons/dc-mesh.svg")), + ("dc-path-eraser", include_bytes!("../../../assets/icons/dc-path-eraser.svg")), + ("dc-pathfinder", include_bytes!("../../../assets/icons/dc-pathfinder.svg")), + ("dc-pen-add", include_bytes!("../../../assets/icons/dc-pen-add.svg")), + ("dc-pen-delete", include_bytes!("../../../assets/icons/dc-pen-delete.svg")), + ("dc-perspective", include_bytes!("../../../assets/icons/dc-perspective.svg")), + ("dc-polar-grid", include_bytes!("../../../assets/icons/dc-polar-grid.svg")), + ("dc-polygon", include_bytes!("../../../assets/icons/dc-polygon.svg")), + ("dc-pucker", include_bytes!("../../../assets/icons/dc-pucker.svg")), + ("dc-puppet", include_bytes!("../../../assets/icons/dc-puppet.svg")), + ("dc-rect-grid", include_bytes!("../../../assets/icons/dc-rect-grid.svg")), + ("dc-reference-point", include_bytes!("../../../assets/icons/dc-reference-point.svg")), + ("dc-reshape", include_bytes!("../../../assets/icons/dc-reshape.svg")), + ("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")), + ("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")), + ("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")), + ("dc-screen-mode", include_bytes!("../../../assets/icons/dc-screen-mode.svg")), + ("dc-selection", include_bytes!("../../../assets/icons/dc-selection.svg")), + ("dc-shape-builder", include_bytes!("../../../assets/icons/dc-shape-builder.svg")), + ("dc-shear", include_bytes!("../../../assets/icons/dc-shear.svg")), + ("dc-smooth", include_bytes!("../../../assets/icons/dc-smooth.svg")), + ("dc-stroke", include_bytes!("../../../assets/icons/dc-stroke.svg")), + ("dc-swap", include_bytes!("../../../assets/icons/dc-swap.svg")), + ("dc-symbol-sprayer", include_bytes!("../../../assets/icons/dc-symbol-sprayer.svg")), + ("dc-touch-type", include_bytes!("../../../assets/icons/dc-touch-type.svg")), + ("dc-transform-panel", include_bytes!("../../../assets/icons/dc-transform-panel.svg")), + ("dc-transparency", include_bytes!("../../../assets/icons/dc-transparency.svg")), + ("dc-twirl", include_bytes!("../../../assets/icons/dc-twirl.svg")), + ("dc-type-area", include_bytes!("../../../assets/icons/dc-type-area.svg")), + ("dc-type-path", include_bytes!("../../../assets/icons/dc-type-path.svg")), + ("dc-type-vertical", include_bytes!("../../../assets/icons/dc-type-vertical.svg")), + ("dc-width", include_bytes!("../../../assets/icons/dc-width.svg")), + ("dc-wrinkle", include_bytes!("../../../assets/icons/dc-wrinkle.svg")), + ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), + ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), + ("eye", include_bytes!("../../../assets/icons/eye.svg")), + ("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")), + ("file-plus", include_bytes!("../../../assets/icons/file-plus.svg")), + ("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")), + ("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")), + ("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")), + ("frame", include_bytes!("../../../assets/icons/frame.svg")), + ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), + ("group", include_bytes!("../../../assets/icons/group.svg")), + ("hand", include_bytes!("../../../assets/icons/hand.svg")), + ("hexagon", include_bytes!("../../../assets/icons/hexagon.svg")), + ("history", include_bytes!("../../../assets/icons/history.svg")), + ("house", include_bytes!("../../../assets/icons/house.svg")), + ("image", include_bytes!("../../../assets/icons/image.svg")), + ("info", include_bytes!("../../../assets/icons/info.svg")), + ("lasso", include_bytes!("../../../assets/icons/lasso.svg")), + ("layers", include_bytes!("../../../assets/icons/layers.svg")), + ("layout-grid", include_bytes!("../../../assets/icons/layout-grid.svg")), + ("library", include_bytes!("../../../assets/icons/library.svg")), + ("link", include_bytes!("../../../assets/icons/link.svg")), + ("link-2-off", include_bytes!("../../../assets/icons/link-2-off.svg")), + ("lock", include_bytes!("../../../assets/icons/lock.svg")), + ("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")), + ("map", include_bytes!("../../../assets/icons/map.svg")), + ("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")), + ("menu", include_bytes!("../../../assets/icons/menu.svg")), + ("minus", include_bytes!("../../../assets/icons/minus.svg")), + ("monitor", include_bytes!("../../../assets/icons/monitor.svg")), + ("move", include_bytes!("../../../assets/icons/move.svg")), + ("move-diagonal-2", include_bytes!("../../../assets/icons/move-diagonal-2.svg")), + ("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")), + ("paintbrush", include_bytes!("../../../assets/icons/paintbrush.svg")), + ("palette", include_bytes!("../../../assets/icons/palette.svg")), + ("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")), + ("pencil", include_bytes!("../../../assets/icons/pencil.svg")), + ("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")), + ("pin", include_bytes!("../../../assets/icons/pin.svg")), + ("pipette", include_bytes!("../../../assets/icons/pipette.svg")), + ("plus", include_bytes!("../../../assets/icons/plus.svg")), + ("printer", include_bytes!("../../../assets/icons/printer.svg")), + ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), + ("rotate-3d", include_bytes!("../../../assets/icons/rotate-3d.svg")), + ("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")), + ("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")), + ("ruler", include_bytes!("../../../assets/icons/ruler.svg")), + ("save", include_bytes!("../../../assets/icons/save.svg")), + ("scaling", include_bytes!("../../../assets/icons/scaling.svg")), + ("scissors", include_bytes!("../../../assets/icons/scissors.svg")), + ("search", include_bytes!("../../../assets/icons/search.svg")), + ("settings", include_bytes!("../../../assets/icons/settings.svg")), + ("shapes", include_bytes!("../../../assets/icons/shapes.svg")), + ("share-2", include_bytes!("../../../assets/icons/share-2.svg")), + ("slice", include_bytes!("../../../assets/icons/slice.svg")), + ("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")), + ("spline", include_bytes!("../../../assets/icons/spline.svg")), + ("spline-pointer", include_bytes!("../../../assets/icons/spline-pointer.svg")), + ("spray-can", include_bytes!("../../../assets/icons/spray-can.svg")), + ("square", include_bytes!("../../../assets/icons/square.svg")), + ("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")), + ("squares-exclude", include_bytes!("../../../assets/icons/squares-exclude.svg")), + ("squares-intersect", include_bytes!("../../../assets/icons/squares-intersect.svg")), + ("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")), + ("squares-unite", include_bytes!("../../../assets/icons/squares-unite.svg")), + ("squircle", include_bytes!("../../../assets/icons/squircle.svg")), + ("star", include_bytes!("../../../assets/icons/star.svg")), + ("sun", include_bytes!("../../../assets/icons/sun.svg")), + ("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")), + ("text-cursor-input", include_bytes!("../../../assets/icons/text-cursor-input.svg")), + ("tornado", include_bytes!("../../../assets/icons/tornado.svg")), + ("trash-2", include_bytes!("../../../assets/icons/trash-2.svg")), + ("triangle", include_bytes!("../../../assets/icons/triangle.svg")), + ("type", include_bytes!("../../../assets/icons/type.svg")), + ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), + ("ungroup", include_bytes!("../../../assets/icons/ungroup.svg")), + ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), + ("waves", include_bytes!("../../../assets/icons/waves.svg")), + ("x", include_bytes!("../../../assets/icons/x.svg")), + ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), +]; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 00000000..ff63e749 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,120 @@ +//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours). + +use std::collections::HashMap; +use std::sync::OnceLock; + +use egui::{Color32, ImageSource, Rect, Ui, Vec2}; + +fn table() -> &'static HashMap<&'static str, &'static [u8]> { + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| { + crate::icon_data::ICONS + .iter() + .map(|(n, b)| { + // Icons use currentColor; render white and tint at draw time. Slightly thinner strokes + // than Lucide's default read closer to Illustrator's glyph weight. + let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\""); + let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice()); + (*n, leaked) + }) + .collect() + }) +} + +pub fn exists(name: &str) -> bool { + table().contains_key(name) +} + +/// Map tool-catalogue icon names to SVG files. +pub fn tool_icon(name: &str) -> &'static str { + match name { + "tool-selection" => "dc-selection", + "tool-direct" => "dc-direct", + "tool-group-select" => "dc-group-select", + "tool-magic-wand" => "wand-sparkles", + "tool-lasso" => "lasso", + "tool-pen" => "pen-tool", + "tool-pen-add" => "dc-pen-add", + "tool-pen-delete" => "dc-pen-delete", + "tool-anchor" => "dc-anchor", + "tool-curvature" => "spline", + "tool-type" => "type", + "tool-type-area" => "dc-type-area", + "tool-type-path" => "dc-type-path", + "tool-type-vertical" => "dc-type-vertical", + "tool-touch-type" => "dc-touch-type", + "tool-line" => "dc-line", + "tool-arc" => "dc-arc", + "tool-spiral" => "tornado", + "tool-rect-grid" => "dc-rect-grid", + "tool-polar-grid" => "dc-polar-grid", + "tool-rect" => "square", + "tool-rounded-rect" => "dc-rounded-rect", + "tool-ellipse" => "dc-ellipse", + "tool-polygon" => "dc-polygon", + "tool-star" => "star", + "tool-flare" => "dc-flare", + "tool-brush" => "paintbrush", + "tool-blob-brush" => "brush", + "tool-shaper" => "shapes", + "tool-pencil" => "pencil", + "tool-smooth" => "dc-smooth", + "tool-path-eraser" => "dc-path-eraser", + "tool-join" => "dc-join", + "tool-eraser" => "eraser", + "tool-scissors" => "scissors", + "tool-knife" => "dc-knife", + "tool-rotate" => "rotate-ccw", + "tool-reflect" => "flip-horizontal-2", + "tool-scale" => "scaling", + "tool-shear" => "dc-shear", + "tool-reshape" => "dc-reshape", + "tool-width" => "dc-width", + "tool-warp" => "waves", + "tool-twirl" => "dc-twirl", + "tool-pucker" => "dc-pucker", + "tool-bloat" => "dc-bloat", + "tool-scallop" => "dc-scallop", + "tool-crystallize" => "dc-crystallize", + "tool-wrinkle" => "dc-wrinkle", + "tool-free-transform" => "dc-free-transform", + "tool-puppet" => "dc-puppet", + "tool-shape-builder" => "dc-shape-builder", + "tool-bucket" => "dc-live-bucket", + "tool-live-select" => "dc-live-select", + "tool-perspective" => "dc-perspective", + "tool-perspective-select" => "dc-perspective", + "tool-mesh" => "dc-mesh", + "tool-gradient" => "dc-gradient", + "tool-eyedropper" => "pipette", + "tool-measure" => "dc-measure", + "tool-blend" => "dc-blend", + "tool-symbol" => "dc-symbol-sprayer", + "tool-graph" => "chart-column", + "tool-artboard" => "frame", + "tool-slice" => "slice", + "tool-hand" => "hand", + "tool-rotate-view" => "dc-rotate-view", + "tool-print-tiling" => "printer", + "tool-zoom" => "zoom-in", + other => if exists(other) { table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") } else { "square-dashed" }, + } +} + +pub fn source(name: &str) -> ImageSource<'static> { + let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]); + ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) } +} + +/// Paint icon `name` into `rect` tinted with `tint`. +pub fn paint(ui: &Ui, name: &str, rect: Rect, tint: Color32) { + egui::Image::new(source(name)).tint(tint).fit_to_exact_size(rect.size()).paint_at(ui, rect); +} + +/// An icon widget of `size` points. +pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), egui::Sense::hover()); + paint(ui, name, rect, tint); + resp +} + diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs new file mode 100644 index 00000000..f5f23cfb --- /dev/null +++ b/crates/ui-egui/src/io.rs @@ -0,0 +1,225 @@ +//! File I/O through the injected services: open, save, export, place. + +use std::sync::Arc; + +use drawcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use drawcraft_geom::Affine; + +use crate::DrawcraftApp; + +fn ext(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +} + +/// Decode an image and make a new document (or place into the active one when `place`). +fn image_doc(name: &str, bytes: &[u8]) -> Result { + let (w, h) = image_size(bytes)?; + let mut d = Document::new(w as f64, h as f64); + d.title = name.to_string(); + let key = format!("img{:016x}", hash(bytes)); + d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) }); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None })); + n.name = Some(name.to_string()); + d.insert(Some(l), 0, n).map_err(|e| e.to_string())?; + Ok(d) +} + +pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> { + // PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding. + if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" { + let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + return Ok((w, h)); + } + if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" { + return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32)); + } + if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 { + let mut i = 2; + while i + 9 < bytes.len() { + if bytes[i] != 0xFF { + i += 1; + continue; + } + let marker = bytes[i + 1]; + let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; + if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32; + let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32; + return Ok((w, h)); + } + i += 2 + len; + } + } + Err("unsupported image format".into()) +} + +fn mime_of(name: &str) -> String { + match ext(name).as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + } + .to_string() +} + +fn hash(b: &[u8]) -> u64 { + // FNV-1a: stable content key for embedded images. + let mut h: u64 = 0xcbf29ce484222325; + for x in b { + h ^= *x as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// Open bytes as a new document. +pub fn open_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { + let e = ext(name); + let (doc, keep_path) = if e == "drawcraft" || drawcraft_format::sniff(bytes) { + let mut d = drawcraft_format::load(bytes).map_err(|e| e.to_string())?; + if d.title.is_empty() { + d.title = name.to_string(); + } + (d, true) + } else if e == "svg" || bytes.starts_with(b" Result<(), String> { + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?; + open_path(app, &path) +} + +pub fn open_path(app: &mut DrawcraftApp, path: &str) -> Result<(), String> { + let read = app.services.read.as_ref().ok_or("no file reader")?; + let bytes = read(path)?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + open_bytes(app, &name, &bytes, Some(path.to_string())) +} + +fn write_out(app: &mut DrawcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { + if let Some(dl) = app.services.download.as_mut() { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + dl(&name, bytes); + return Ok(()); + } + let w = app.services.write.as_mut().ok_or("no file writer")?; + w(path, bytes) +} + +fn suggested(app: &DrawcraftApp, ext: &str) -> String { + let t = app.session.active().map(|d| d.title()).unwrap_or_else(|| "Untitled".into()); + let stem = std::path::Path::new(&t).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(t); + format!("{stem}.{ext}") +} + +/// File → Save / Save As (native format). +pub fn save(app: &mut DrawcraftApp, path: Option, save_as: bool) -> Result { + let st = app.session.active().ok_or("no document")?; + let existing = if save_as { None } else { st.path.clone() }; + let path = match path.or(existing) { + Some(p) => p, + None if app.services.download.is_some() => suggested(app, "drawcraft"), + None => { + let s = suggested(app, "drawcraft"); + app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? + } + }; + let bytes = drawcraft_format::save(&app.session.active().unwrap().doc, true); + write_out(app, &path, &bytes)?; + if let Some(st) = app.session.active_mut() { + st.path = Some(path.clone()); + st.saved_revision = st.revision; + } + app.status(format!("Saved {path}")); + Ok(path) +} + +/// Export the document as SVG / PNG / JPEG. +pub fn export(app: &mut DrawcraftApp, format: &str, path: Option, scale: f64) -> Result { + let st = app.session.active().ok_or("no document")?; + let doc = st.doc.clone(); + let path = match path { + Some(p) => p, + None if app.services.download.is_some() => suggested(app, format), + None => { + let s = suggested(app, format); + app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")? + } + }; + let bytes = match format { + "svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(), + "png" | "jpg" | "jpeg" => { + let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?; + let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png"); + if format == "png" { img.to_png() } else { encode_jpeg(&img)? } + } + other => return Err(format!("unknown export format `{other}`")), + }; + write_out(app, &path, &bytes)?; + app.status(format!("Exported {path}")); + Ok(path) +} + +fn encode_jpeg(img: &drawcraft_render::Rendered) -> Result, String> { + let _ = img; + Err("JPEG export lands with M4.6".into()) +} + +/// File → Place… (embed an image or SVG into the active document). +pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { + let e = ext(name); + if e == "svg" { + let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?; + let src = drawcraft_svg::import(s).map_err(|e| e.to_string())?; + let nodes: Vec = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); + app.session.clipboard = nodes; + app.run("edit.pasteInPlace", serde_json::json!({}))?; + return Ok(()); + } + let (w, h) = image_size(bytes)?; + let key = format!("img{:016x}", hash(bytes)); + let st = app.session.active().ok_or("no document")?; + let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default(); + let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0); + let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s); + let mime = mime_of(name); + let bytes = Arc::new(bytes.to_vec()); + let parent = st.insertion_parent(); + let name = name.to_string(); + app.session + .edit("Place", |d, sel| { + d.images.insert(key.clone(), ImageBlob { mime, bytes }); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); + n.name = Some(name); + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(()) + }) + .map_err(|e| e.to_string()) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1209a858..b6c5e9c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1 +1,372 @@ -//! stub +//! DrawCraft's egui frontend: an Illustrator-style UI over `drawcraft-engine`. +//! +//! The UI is thin: every action goes through [`DrawcraftApp::run`], which dispatches UI commands +//! (view/window) here and everything else to the engine. The same entry point serves menus, +//! shortcuts, the ⌘K palette and the control channel ([`control`]). +#![forbid(unsafe_code)] + +pub mod canvas; +pub mod chrome; +pub mod control; +pub mod dialogs; +pub mod dock; +pub mod icon_data; +pub mod icons; +pub mod io; +pub mod menus; +pub mod palette; +pub mod panels; +pub mod shortcuts; +pub mod state; +pub mod theme; +pub mod toolbar; +pub mod widgets; + +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; + +use drawcraft_engine::{Session, ViewInfo}; +use serde_json::{Value, json}; + +pub use control::{ControlRequest, ControlResponse}; +pub use state::{UiState, View}; + +/// Platform services injected by the host app (desktop or web). +#[derive(Default)] +pub struct Services { + /// Show an open dialog; returns a path. + pub pick_open: Option Option>>, + /// Show a save dialog with a suggested file name; returns a path. + pub pick_save: Option Option>>, + pub read: Option Result, String>>>, + pub write: Option Result<(), String>>>, + /// Files that arrived asynchronously (web open / drops): (name, bytes). + pub inbox: Option)>>>>, + /// Web: trigger a browser download instead of writing a path. + pub download: Option>, + /// Web: start an async open (bytes arrive via `inbox`). + pub open_async: Option>, +} + +/// Cached canvas raster. +pub struct CanvasCache { + pub renderer: drawcraft_render::Renderer, + pub texture: Option, + pub key: Option, + pub last_ms: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CacheKey { + pub doc: usize, + pub revision: u64, + pub zoom: f64, + pub cx: f64, + pub cy: f64, + pub w: u32, + pub h: u32, + pub outline: bool, + pub ppp: f32, + pub hidden: Vec, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Perf { + pub frame_ms: f64, + pub render_ms: f64, + pub fps: f64, +} + +pub struct DrawcraftApp { + pub session: Session, + pub ui: UiState, + pub views: Vec, + pub services: Services, + pub canvas: CanvasCache, + pub perf: Perf, + /// macOS: draw our own title strip under the traffic lights. + pub integrated_titlebar: bool, + control_rx: Option>, + pending_screenshots: Vec<(u64, Option, Sender)>, + queued_screenshots: Vec<(u64, f64, u32)>, + screenshot_token: u64, + /// Synthetic input events (from the control channel) injected one step per frame. + pub synthetic: Vec, + styled: bool, + fonts_ready: bool, + frame: u64, + last_time: f64, + /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. + pub canvas_rect: Option, + /// Hover position in document coordinates. + pub hover_doc: Option, +} + +impl DrawcraftApp { + pub fn new(session: Session, services: Services) -> Self { + let views = session.documents().iter().map(|_| View::default()).collect(); + Self { + session, + ui: UiState::default(), + views, + services, + canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0 }, + perf: Perf::default(), + integrated_titlebar: false, + control_rx: None, + pending_screenshots: vec![], + queued_screenshots: vec![], + screenshot_token: 0, + synthetic: vec![], + styled: false, + fonts_ready: false, + frame: 0, + last_time: 0.0, + canvas_rect: None, + hover_doc: None, + } + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Keep `views` aligned with the session's documents. + pub fn sync_views(&mut self) { + let n = self.session.documents().len(); + while self.views.len() < n { + self.views.push(View::default()); + } + self.views.truncate(n); + } + + pub fn view(&self) -> Option<&View> { + self.session.active_index().and_then(|i| self.views.get(i)) + } + pub fn view_mut(&mut self) -> Option<&mut View> { + self.sync_views(); + let i = self.session.active_index()?; + self.views.get_mut(i) + } + + pub fn view_info(&self) -> ViewInfo { + ViewInfo { + zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + outline: self.ui.view.outline, + smart_guides: self.ui.view.smart_guides, + snap_to_grid: self.ui.view.snap_to_grid, + show_bbox: self.ui.view.bounding_box, + } + } + + /// Run a UI or engine command by id. The single entry point for every frontend path. + pub fn run(&mut self, id: &str, params: Value) -> Result { + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + return r; + } + let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + self.sync_views(); + match &r { + Err(e) => self.ui.status = e.clone(), + Ok(_) => { + if id == "file.new" { + self.ui.status.clear(); + } + } + } + r + } + + /// Select a tool (also used by the toolbar and shortcuts). + pub fn select_tool(&mut self, id: &str) { + let v = self.view_info(); + if let Err(e) = self.session.select_tool(id, v) { + self.ui.status = e.to_string(); + } + if let Some(g) = drawcraft_tools::catalog::group_of(id) + && let Some(slot) = self.ui.group_tool.get_mut(g) + { + *slot = id.to_string(); + } + self.ui.flyout = None; + } + + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + self.screenshot_token += 1; + let token = self.screenshot_token; + let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; + self.queued_screenshots.push((token, now_ms() + settle, 0)); + self.pending_screenshots.push((token, path, reply)); + } + } + } + self.control_rx = Some(rx); + } + + fn issue_screenshots(&mut self, ctx: &egui::Context) { + let now = now_ms(); + self.queued_screenshots.retain_mut(|(token, at, frames)| { + *frames += 1; + if now >= *at && *frames >= 3 { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + false + } else { + true + } + }); + if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let events: Vec<_> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; + Some((token, image.clone())) + } + _ => None, + }) + .collect() + }); + for (token, image) in events { + if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) { + let (_, path, reply) = self.pending_screenshots.remove(i); + let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); + } + } + } + + /// Show a transient status message. + pub fn status(&mut self, s: impl Into) { + self.ui.status = s.into(); + } + + fn drain_inbox(&mut self) { + let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (name, bytes) in arrived { + if let Err(e) = io::open_bytes(self, &name, &bytes, None) { + self.status(format!("Couldn't open {name}: {e}")); + } + } + } +} + +pub fn now_ms() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + } + #[cfg(target_arch = "wasm32")] + { + 0.0 + } +} + +/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are +/// called from the host's `eframe::App` impl. +impl DrawcraftApp { + /// Per-frame logic before layout (control channel, shortcuts, inbox). + pub fn logic(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::install_fonts(ctx); + theme::apply(ctx, self.ui.brightness); + egui_extras::install_image_loaders(ctx); + self.styled = true; + } else { + self.fonts_ready = true; + } + self.frame += 1; + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.sync_views(); + self.drain_control(ctx); + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + self.drain_inbox(); + if self.fonts_ready { + shortcuts::handle(self, ctx); + } + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + for f in dropped { + let path = f.path().to_path_buf(); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes() { + Ok(b) => { + let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty()); + if let Err(e) = io::open_bytes(self, &name, &b, p) { + self.status(format!("Couldn't open {name}: {e}")); + } + } + Err(e) => self.status(format!("Couldn't read {name}: {e}")), + } + } + } + + /// Inject synthetic events (one press/release step per frame). + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if !self.synthetic.is_empty() { + let n = self.synthetic.iter().position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1); + raw.events.extend(self.synthetic.drain(..n)); + } + } + + /// Lay out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + let t0 = now_ms(); + let t = theme::Tokens::get(&ctx); + if self.ui.screen_mode < 2 { + chrome::app_bar(self, ui); + if self.ui.control_bar { + chrome::control_bar(self, ui); + } + } + if self.ui.status_bar && self.ui.screen_mode < 3 { + chrome::status_bar(self, ui); + } + if self.ui.toolbar && self.ui.screen_mode < 3 { + toolbar::show(self, ui); + } + if self.ui.dock && self.ui.screen_mode < 3 { + dock::show(self, ui); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { + if self.ui.screen_mode < 3 { + chrome::doc_tabs(self, ui); + } + canvas::show(self, ui); + }); + dock::floating_panel(self, &ctx); + dialogs::show(self, &ctx); + palette::show(self, &ctx); + self.perf.frame_ms = now_ms() - t0; + let _ = json!(null); + } +} + diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 00000000..6a6cf601 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,939 @@ +//! The menu bar (Illustrator's menu tree) and UI-level commands. +//! +//! Items bound to a command id run through [`DrawcraftApp::run`]. Items not implemented yet are +//! listed (disabled, with their shortcut) so the full surface is visible and discoverable; the +//! parity tracker drives them to "done". + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::DrawcraftApp; +use crate::io; +use crate::state::{DockTab, ICON_PANELS, next_zoom}; +use crate::theme::{self, Brightness, Tokens}; + +#[derive(Clone, Debug)] +pub enum Item { + /// (label, command id, params) + Cmd(&'static str, &'static str, Value), + /// Not implemented yet: (label, shortcut) + Todo(&'static str, &'static str), + Sub(&'static str, Vec), + Sep, + /// Section header (disabled label, e.g. "Illustrator Effects"). + Header(&'static str), +} + +fn c(label: &'static str, id: &'static str) -> Item { + Item::Cmd(label, id, Value::Null) +} +fn cp(label: &'static str, id: &'static str, p: Value) -> Item { + Item::Cmd(label, id, p) +} +fn todo(label: &'static str) -> Item { + Item::Todo(label, "") +} +fn todos(label: &'static str, sc: &'static str) -> Item { + Item::Todo(label, sc) +} +fn sub(label: &'static str, items: Vec) -> Item { + Item::Sub(label, items) +} +use Item::Sep; + +/// UI-level commands: (id, label, shortcut, params doc). +pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ("file.open", "Open…", "Cmd+O", "{path?}"), + ("file.save", "Save", "Cmd+S", "{path?}"), + ("file.saveAs", "Save As…", "Cmd+Shift+S", "{path?}"), + ("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"), + ("file.revert", "Revert", "F12", "{}"), + ("file.place", "Place…", "Cmd+Shift+P", "{path?}"), + ("file.export.svg", "Export As SVG…", "", "{path?}"), + ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"), + ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"), + ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), + ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ("edit.preferences", "Preferences…", "Cmd+K", "{}"), + ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), + ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), + ("view.zoomIn", "Zoom In", "Cmd+=", "{}"), + ("view.zoomOut", "Zoom Out", "Cmd+-", "{}"), + ("view.fitArtboard", "Fit Artboard in Window", "Cmd+0", "{}"), + ("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"), + ("view.actualSize", "Actual Size", "Cmd+1", "{}"), + ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), + ("view.edges", "Hide Edges", "Cmd+H", "{}"), + ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), + ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), + ("view.transparencyGrid", "Show Transparency Grid", "Cmd+Shift+D", "{}"), + ("view.guides", "Hide Guides", "Cmd+;", "{}"), + ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), + ("view.grid", "Show Grid", "Cmd+'", "{}"), + ("view.snapToGrid", "Snap to Grid", "Cmd+Shift+'", "{}"), + ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), + ("view.presentation", "Presentation Mode", "Shift+F", "{}"), + ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), + ("window.control", "Control", "", "{}"), + ("window.toolbar", "Tools", "", "{}"), + ("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"), + ("window.dock", "Panels", "Tab", "{} show/hide all panels"), + ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"), + ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), + ("window.workspace.reset", "Reset Essentials", "", "{}"), + ("window.newWindow", "New Window", "", "{}"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.setOption", "Tool Option", "", "{key, value}"), + ("help.about", "About DrawCraft", "", "{}"), + ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), + ("app.quit", "Quit DrawCraft", "Cmd+Q", "{}"), +]; + +/// Handle a UI command. `None` = not a UI command (the engine handles it). +pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let flag = |b: &mut bool| { + *b = !*b; + Ok(json!(*b)) + }; + let r = match id { + "file.newDialog" => { + app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); + Ok(Value::Null) + } + "file.open" => match s("path") { + Some(path) => io::open_path(app, &path).map(|_| Value::Null), + None => io::open_dialog(app).map(|_| Value::Null), + }, + "file.save" => io::save(app, s("path"), false).map(|p| json!({"path": p})), + "file.saveAs" | "file.saveCopy" => io::save(app, s("path"), true).map(|p| json!({"path": p})), + "file.revert" => { + let path = app.session.active().and_then(|d| d.path.clone()); + match path { + Some(path) => { + let i = app.session.active_index().unwrap_or(0); + app.session.close_document(i); + io::open_path(app, &path).map(|_| Value::Null) + } + None => Err("document has never been saved".into()), + } + } + "file.place" => { + let path = match s("path") { + Some(p) => Some(p), + None => app.services.pick_open.as_mut().and_then(|f| f()), + }; + match path { + Some(path) => { + let bytes = app.services.read.as_ref().ok_or("no reader".to_string()).and_then(|r| r(&path)); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + bytes.and_then(|b| io::place_bytes(app, &name, &b)).map(|_| Value::Null) + } + None => Err("cancelled".into()), + } + } + "file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})), + "file.export.png" | "file.exportForScreens" => io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})), + "file.documentSetup" => { + let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points"); + app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units}))); + Ok(Value::Null) + } + "edit.preferences" => { + app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}))); + Ok(Value::Null) + } + "view.outline" => flag(&mut app.ui.view.outline), + "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), + "view.edges" => flag(&mut app.ui.view.edges), + "view.artboards" => flag(&mut app.ui.view.artboards), + "view.rulers" => flag(&mut app.ui.view.rulers), + "view.boundingBox" => flag(&mut app.ui.view.bounding_box), + "view.transparencyGrid" => flag(&mut app.ui.view.transparency_grid), + "view.guides" => flag(&mut app.ui.view.guides), + "view.smartGuides" => flag(&mut app.ui.view.smart_guides), + "view.grid" => flag(&mut app.ui.view.grid), + "view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid), + "view.snapToPoint" => flag(&mut app.ui.view.snap_to_point), + "view.zoomIn" | "view.zoomOut" => { + let up = id == "view.zoomIn"; + match app.view_mut() { + Some(v) => { + v.zoom = next_zoom(v.zoom, up); + Ok(json!({"zoom": v.zoom * 100.0})) + } + None => Err("no document".into()), + } + } + "view.setZoom" => { + let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0; + let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + match app.view_mut() { + Some(v) => { + v.zoom = z.clamp(0.0313, 640.0); + v.fitted = true; + if let Some(c) = center { + v.center = c; + } + Ok(Value::Null) + } + None => Err("no document".into()), + } + } + "view.fitArtboard" | "view.fitAll" | "view.actualSize" => { + crate::canvas::fit(app, id); + Ok(Value::Null) + } + "view.presentation" => { + app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 }; + Ok(json!(app.ui.screen_mode)) + } + "view.screenMode" => { + app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + Some(m) => m.min(3) as u8, + None => (app.ui.screen_mode + 1) % 3, + }; + Ok(json!(app.ui.screen_mode)) + } + "view.rotateReset" => { + if let Some(v) = app.view_mut() { + v.rotation = 0.0; + } + Ok(Value::Null) + } + "window.control" => flag(&mut app.ui.control_bar), + "window.toolbar" => flag(&mut app.ui.toolbar), + "window.toolbarColumns" => flag(&mut app.ui.toolbar_double), + "window.dock" => { + let on = !(app.ui.dock && app.ui.toolbar); + app.ui.dock = on; + app.ui.toolbar = on; + app.ui.control_bar = on; + Ok(json!(on)) + } + "window.panel" => { + let panel = s("panel").unwrap_or_default(); + match panel.as_str() { + "properties" => { + app.ui.dock_tab = DockTab::Properties; + Ok(Value::Null) + } + "layers" => { + app.ui.dock_tab = DockTab::Layers; + Ok(Value::Null) + } + "libraries" => { + app.ui.dock_tab = DockTab::Libraries; + Ok(Value::Null) + } + p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => { + app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) }; + app.ui.dock = true; + Ok(json!({"open": app.ui.open_panel})) + } + other => Err(format!("unknown panel `{other}`")), + } + } + "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { + Some(b) => { + app.ui.brightness = b; + app.canvas.key = None; + Ok(json!(b.id())) + } + None => Err("brightness must be dark|mediumDark|mediumLight|light".into()), + }, + "window.workspace.reset" => { + let b = app.ui.brightness; + app.ui = crate::state::UiState { brightness: b, ..Default::default() }; + Ok(Value::Null) + } + "window.newWindow" => Err("multiple windows land with M11.5".into()), + "tool.select" => match s("tool") { + Some(t) if drawcraft_tools::tool_info(&t).is_some() => { + app.select_tool(&t); + Ok(json!({"tool": app.session.tool_id()})) + } + Some(t) => Err(format!("unknown tool `{t}`")), + None => Err("missing `tool`".into()), + }, + "tool.setOption" => { + let k = s("key").unwrap_or_default(); + app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null)); + Ok(app.session.tool_options()) + } + "help.about" => { + app.ui.about = true; + Ok(Value::Null) + } + "help.commandPalette" => { + app.ui.palette_open = !app.ui.palette_open; + app.ui.palette_query.clear(); + Ok(Value::Null) + } + "app.quit" => { + app.ui.status = "quit".into(); + Ok(Value::Null) + } + _ => return None, + }; + Some(r) +} + +/// Checked state for toggle items. +pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option { + let v = &app.ui.view; + Some(match id { + "view.outline" => v.outline, + "view.pixelPreview" => v.pixel_preview, + "view.smartGuides" => v.smart_guides, + "view.grid" => v.grid, + "view.snapToGrid" => v.snap_to_grid, + "view.snapToPoint" => v.snap_to_point, + "view.rulers" => v.rulers, + "window.control" => app.ui.control_bar, + "window.toolbar" => app.ui.toolbar, + "window.panel" => { + let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); + match panel { + "properties" => app.ui.dock_tab == DockTab::Properties, + "layers" => app.ui.dock_tab == DockTab::Layers, + "libraries" => app.ui.dock_tab == DockTab::Libraries, + _ => app.ui.open_panel.as_deref() == Some(panel), + } + } + "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), + _ => return None, + }) +} + +/// Label for toggles whose text flips (Outline/Preview, Hide/Show …). +fn dynamic_label(app: &DrawcraftApp, id: &str, label: &'static str) -> String { + let v = &app.ui.view; + match id { + "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), + "view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(), + "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), + "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), + "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), + "view.transparencyGrid" => if v.transparency_grid { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into(), + "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), + "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), + "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), + _ => label.into(), + } +} + +pub fn shortcut_of(id: &str) -> Option<&'static str> { + if let Some(c) = drawcraft_engine::find_command(id) { + return c.shortcut; + } + UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty()) +} + +/// Is a command currently enabled? +pub fn enabled(app: &DrawcraftApp, id: &str) -> bool { + if let Some(c) = drawcraft_engine::find_command(id) { + return (c.enabled)(&app.session).is_ok(); + } + match id { + "file.save" | "file.saveAs" | "file.saveCopy" | "file.place" | "file.export.svg" | "file.export.png" | "file.exportForScreens" | "file.documentSetup" | "view.zoomIn" | "view.zoomOut" | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), + "file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()), + _ => true, + } +} + +pub fn menu_tree() -> Vec<(&'static str, Vec)> { + let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id })); + vec![ + ( + "DrawCraft", + vec![c("About DrawCraft", "help.about"), Sep, c("Settings…", "edit.preferences"), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, c("Quit DrawCraft", "app.quit")], + ), + ( + "File", + vec![ + c("New…", "file.newDialog"), + todos("New from Template…", "Cmd+Shift+N"), + c("Open…", "file.open"), + todo("Open Recent Files"), + Sep, + c("Close", "file.close"), + todos("Close All", "Cmd+Alt+W"), + c("Save", "file.save"), + c("Save As…", "file.saveAs"), + c("Save a Copy…", "file.saveCopy"), + todo("Save as Template…"), + c("Revert", "file.revert"), + Sep, + c("Place…", "file.place"), + Sep, + sub("Export", vec![c("Export for Screens…", "file.exportForScreens"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S")]), + todo("Export Selection…"), + Sep, + todos("Package…", "Cmd+Alt+Shift+P"), + sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]), + Sep, + c("Document Setup…", "file.documentSetup"), + sub("Document Color Mode", vec![todo("CMYK Color"), todo("RGB Color")]), + todos("File Info…", "Cmd+Alt+Shift+I"), + Sep, + todos("Print…", "Cmd+P"), + ], + ), + ( + "Edit", + vec![ + c("Undo", "edit.undo"), + c("Redo", "edit.redo"), + Sep, + c("Cut", "edit.cut"), + c("Copy", "edit.copy"), + c("Paste", "edit.paste"), + c("Paste in Front", "edit.pasteInFront"), + c("Paste in Back", "edit.pasteInBack"), + c("Paste in Place", "edit.pasteInPlace"), + c("Paste on All Artboards", "edit.pasteOnAllArtboards"), + todos("Paste without Formatting", "Cmd+Alt+V"), + c("Clear", "edit.clear"), + Sep, + todo("Find and Replace…"), + todo("Find Next"), + sub("Spelling", vec![todo("Auto Spell Check"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")]), + Sep, + sub( + "Edit Colors", + vec![ + todo("Recolor Artwork…"), + todo("Adjust Color Balance…"), + todo("Blend Front to Back"), + todo("Blend Horizontally"), + todo("Blend Vertically"), + todo("Convert to CMYK"), + todo("Convert to Grayscale"), + todo("Convert to RGB"), + todo("Invert Colors"), + todo("Overprint Black…"), + todo("Saturate…"), + ], + ), + todo("Edit Original"), + Sep, + todo("Transparency Flattener Presets…"), + todo("Print Presets…"), + todo("Adobe PDF Presets…"), + todo("Perspective Grid Presets…"), + Sep, + todos("Color Settings…", "Cmd+Shift+K"), + todo("Assign Profile…"), + Sep, + todos("Keyboard Shortcuts…", "Cmd+Alt+Shift+K"), + c("Preferences…", "edit.preferences"), + ], + ), + ( + "Object", + vec![ + sub( + "Transform", + vec![ + c("Transform Again", "object.transformAgain"), + Sep, + c("Move…", "object.move"), + c("Rotate…", "object.rotate"), + c("Reflect…", "object.reflect"), + c("Scale…", "object.scale"), + c("Shear…", "object.shear"), + Sep, + todos("Transform Each…", "Cmd+Alt+Shift+D"), + Sep, + todo("Reset Bounding Box"), + ], + ), + sub( + "Arrange", + vec![ + c("Bring to Front", "object.arrange.bringToFront"), + c("Bring Forward", "object.arrange.bringForward"), + c("Send Backward", "object.arrange.sendBackward"), + c("Send to Back", "object.arrange.sendToBack"), + Sep, + c("Send to Current Layer", "object.arrange.sendToCurrentLayer"), + ], + ), + sub( + "Align", + vec![ + cp("Horizontal Align Left", "object.align", json!({"horizontal": "left"})), + cp("Horizontal Align Center", "object.align", json!({"horizontal": "center"})), + cp("Horizontal Align Right", "object.align", json!({"horizontal": "right"})), + cp("Vertical Align Top", "object.align", json!({"vertical": "top"})), + cp("Vertical Align Center", "object.align", json!({"vertical": "center"})), + cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), + ], + ), + Sep, + c("Group", "object.group"), + c("Ungroup", "object.ungroup"), + sub("Lock", vec![c("Selection", "object.lock"), todo("All Artwork Above"), todo("Other Layers")]), + c("Unlock All", "object.unlockAll"), + sub("Hide", vec![c("Selection", "object.hide"), todo("All Artwork Above"), todo("Other Layers")]), + c("Show All", "object.showAll"), + Sep, + todo("Expand…"), + todo("Expand Appearance"), + todo("Crop Image"), + todo("Rasterize…"), + todo("Create Gradient Mesh…"), + todo("Create Object Mosaic…"), + todo("Create Trim Marks"), + todo("Flatten Transparency…"), + Sep, + todo("Make Pixel Perfect"), + Sep, + sub("Slice", vec![todo("Make"), todo("Release"), todo("Create from Guides"), todo("Create from Selection")]), + Sep, + sub( + "Path", + vec![ + c("Join", "path.join"), + c("Average…", "path.average"), + Sep, + c("Outline Stroke", "object.path.outlineStroke"), + c("Offset Path…", "object.path.offsetPath"), + c("Reverse Path Direction", "path.reverse"), + Sep, + c("Simplify…", "object.path.simplify"), + c("Add Anchor Points", "object.path.addAnchorPoints"), + c("Remove Anchor Points", "path.deleteAnchors"), + todo("Divide Objects Below"), + c("Split Into Grid…", "object.path.splitIntoGrid"), + Sep, + todo("Clean Up…"), + ], + ), + sub("Shape", vec![todo("Convert to Shape"), c("Expand Shape", "object.expandShape")]), + sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]), + sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]), + sub("Blend", vec![todos("Make", "Cmd+Alt+B"), todos("Release", "Cmd+Alt+Shift+B"), Sep, todo("Blend Options…"), Sep, todo("Expand"), Sep, todo("Replace Spine"), todo("Reverse Spine"), todo("Reverse Front to Back")]), + sub("Envelope Distort", vec![todos("Make with Warp…", "Cmd+Alt+Shift+W"), todos("Make with Mesh…", "Cmd+Alt+M"), todos("Make with Top Object", "Cmd+Alt+C"), Sep, todo("Release"), todo("Envelope Options…"), todo("Expand")]), + sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]), + sub("Live Paint", vec![todos("Make", "Cmd+Alt+X"), todo("Merge"), todo("Release"), Sep, todo("Gap Options…"), Sep, todo("Expand")]), + sub("Image Trace", vec![todo("Make"), todo("Make and Expand"), todo("Release"), todo("Expand")]), + sub("Text Wrap", vec![todo("Make"), todo("Release"), todo("Text Wrap Options…")]), + Sep, + sub("Clipping Mask", vec![c("Make", "object.clippingMask.make"), c("Release", "object.clippingMask.release"), todo("Edit Contents")]), + sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]), + sub("Artboards", vec![todo("Convert to Artboards"), todo("Rearrange All Artboards…"), Sep, c("Fit to Artwork Bounds", "artboard.fitToArt"), c("Fit to Selected Art", "artboard.fitToSelection")]), + sub("Graph", vec![todo("Type…"), todo("Data…"), todo("Design…"), todo("Column…"), todo("Marker…")]), + ], + ), + ( + "Type", + vec![ + sub("Font", drawcraft_text::FontDb::global().families().into_iter().take(0).map(|_| todo("")).chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]).collect()), + todo("Recent Fonts"), + sub("Size", [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72].iter().map(|_| todo("pt")).take(0).chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]).collect()), + Sep, + todo("Glyphs"), + Sep, + todo("Convert To Area Type"), + todo("Area Type Options…"), + sub("Type on a Path", vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")]), + sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]), + todo("Fit Headline"), + Sep, + todo("Find Font…"), + sub("Change Case", vec![todo("UPPERCASE"), todo("lowercase"), todo("Title Case"), todo("Sentence case")]), + todo("Smart Punctuation…"), + Sep, + c("Create Outlines", "type.createOutlines"), + todo("Optical Margin Alignment"), + Sep, + todo("Fill with Placeholder Text"), + Sep, + todos("Show Hidden Characters", "Cmd+Alt+I"), + sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]), + ], + ), + ( + "Select", + vec![ + c("All", "select.all"), + c("All on Active Artboard", "select.allOnArtboard"), + c("Deselect", "select.none"), + c("Reselect", "select.reselect"), + Sep, + c("Inverse", "select.inverse"), + Sep, + c("Next Object Above", "select.nextAbove"), + c("Next Object Below", "select.nextBelow"), + Sep, + sub( + "Same", + vec![ + c("Appearance", "select.same.appearance"), + todo("Appearance Attribute"), + c("Blending Mode", "select.same.blendingMode"), + c("Fill & Stroke", "select.same.fillAndStroke"), + c("Fill Color", "select.same.fillColor"), + c("Opacity", "select.same.opacity"), + c("Stroke Color", "select.same.strokeColor"), + c("Stroke Weight", "select.same.strokeWeight"), + todo("Graphic Style"), + c("Shape", "select.same.shapeType"), + todo("Symbol Instance"), + Sep, + todo("Font Family"), + todo("Font Size"), + todo("Text Fill Color"), + ], + ), + sub( + "Object", + vec![ + c("All on Same Layers", "select.object.allOnSameLayers"), + todo("Direction Handles"), + Sep, + todo("Brush Strokes"), + c("Clipping Masks", "select.object.clippingMasks"), + c("Stray Points", "select.object.strayPoints"), + c("Open Paths", "select.object.openPaths"), + Sep, + c("All Text Objects", "select.object.textObjects"), + ], + ), + todo("Start Global Edit"), + Sep, + todo("Save Selection…"), + todo("Edit Selection…"), + ], + ), + ( + "Effect", + vec![ + todos("Apply Last Effect", "Cmd+Shift+E"), + todos("Last Effect", "Cmd+Alt+Shift+E"), + Sep, + todo("Document Raster Effects Settings…"), + Sep, + Item::Header("Illustrator Effects"), + sub("3D and Materials", vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]), + sub("Convert to Shape", vec![todo("Rectangle…"), todo("Rounded Rectangle…"), todo("Ellipse…")]), + todo("Crop Marks"), + sub("Distort & Transform", vec![todo("Free Distort…"), todo("Pucker & Bloat…"), todo("Roughen…"), todo("Transform…"), todo("Tweak…"), todo("Twist…"), todo("Zig Zag…")]), + sub("Path", vec![todo("Offset Path…"), todo("Outline Object"), todo("Outline Stroke")]), + sub("Pathfinder", vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract"), todo("Minus Back"), Sep, todo("Divide"), todo("Trim"), todo("Merge"), todo("Crop"), todo("Outline")]), + todo("Rasterize…"), + sub("Stylize", vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")]), + sub("SVG Filters", vec![todo("Apply SVG Filter…"), todo("Import SVG Filter…")]), + sub( + "Warp", + vec![ + todo("Arc…"), + todo("Arc Lower…"), + todo("Arc Upper…"), + todo("Arch…"), + todo("Bulge…"), + todo("Shell Lower…"), + todo("Shell Upper…"), + todo("Flag…"), + todo("Wave…"), + todo("Fish…"), + todo("Rise…"), + todo("Fisheye…"), + todo("Inflate…"), + todo("Squeeze…"), + todo("Twist…"), + ], + ), + Sep, + Item::Header("Raster Effects"), + todo("Effect Gallery…"), + sub("Blur", vec![todo("Gaussian Blur…"), todo("Radial Blur…"), todo("Smart Blur…")]), + sub("Pixelate", vec![todo("Color Halftone…"), todo("Crystallize…"), todo("Mezzotint…"), todo("Pointillize…")]), + sub("Stylize", vec![todo("Glowing Edges…")]), + ], + ), + ( + "View", + vec![ + c("Outline", "view.outline"), + todos("Overprint Preview", "Cmd+Alt+Shift+Y"), + c("Pixel Preview", "view.pixelPreview"), + todo("Trim View"), + c("Presentation Mode", "view.presentation"), + sub("Screen Mode", vec![cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), cp("Full Screen Mode", "view.screenMode", json!({"mode": 2}))]), + Sep, + sub("Proof Setup", vec![todo("Working CMYK"), todo("Internet Standard RGB (sRGB)"), todo("Color blindness – Protanopia-type"), todo("Color blindness – Deuteranopia-type")]), + todo("Proof Colors"), + Sep, + c("Zoom In", "view.zoomIn"), + c("Zoom Out", "view.zoomOut"), + c("Fit Artboard in Window", "view.fitArtboard"), + c("Fit All in Window", "view.fitAll"), + c("Actual Size", "view.actualSize"), + Sep, + c("Reset Rotate View", "view.rotateReset"), + Sep, + c("Hide Edges", "view.edges"), + c("Hide Artboards", "view.artboards"), + todo("Show Print Tiling"), + Sep, + sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]), + c("Hide Bounding Box", "view.boundingBox"), + c("Show Transparency Grid", "view.transparencyGrid"), + todos("Hide Text Threads", "Cmd+Shift+Y"), + todos("Hide Gradient Annotator", "Cmd+Alt+G"), + todo("Hide Corner Widget"), + Sep, + sub("Guides", vec![c("Hide Guides", "view.guides"), todos("Lock Guides", "Cmd+Alt+;"), todos("Make Guides", "Cmd+5"), todos("Release Guides", "Cmd+Alt+5"), todo("Clear Guides")]), + c("Smart Guides", "view.smartGuides"), + sub("Perspective Grid", vec![todos("Show Grid", "Cmd+Shift+I"), todo("Define Grid…")]), + c("Show Grid", "view.grid"), + c("Snap to Grid", "view.snapToGrid"), + todo("Snap to Pixel"), + c("Snap to Point", "view.snapToPoint"), + todo("Snap to Glyph"), + Sep, + todo("New View…"), + todo("Edit Views…"), + ], + ), + ( + "Window", + vec![ + c("New Window", "window.newWindow"), + sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]), + sub("Workspace", vec![todo("Essentials"), todo("Essentials Classic"), todo("Painting"), todo("Typography"), Sep, c("Reset Essentials", "window.workspace.reset")]), + Sep, + c("Control", "window.control"), + c("Tools", "window.toolbar"), + sub("Toolbars", vec![c("Single / Double Column", "window.toolbarColumns")]), + Sep, + todo("Actions"), + panel("Align", "align"), + panel("Appearance", "appearance"), + panel("Artboards", "artboards"), + todo("Asset Export"), + todo("Attributes"), + panel("Brushes", "brushes"), + panel("Color", "color"), + panel("Color Guide", "colorGuide"), + todo("Document Info"), + panel("Gradient", "gradient"), + panel("Graphic Styles", "graphicStyles"), + panel("History", "history"), + todo("Image Trace"), + panel("Info", "info"), + panel("Layers", "layers"), + panel("Libraries", "libraries"), + todo("Links"), + todo("Magic Wand"), + panel("Navigator", "navigator"), + panel("Pathfinder", "pathfinder"), + todo("Pattern Options"), + panel("Properties", "properties"), + todo("Separations Preview"), + panel("Stroke", "stroke"), + todo("SVG Interactivity"), + panel("Swatches", "swatches"), + panel("Symbols", "symbols"), + panel("Transform", "transform"), + panel("Transparency", "transparency"), + sub("Type", vec![panel("Character", "character"), todo("Character Styles"), todo("Glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), todo("Paragraph Styles"), todo("Tabs")]), + todo("Variables"), + Sep, + sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]), + sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]), + sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]), + sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]), + ], + ), + ("Help", vec![c("Search Commands…", "help.commandPalette"), todos("DrawCraft Help…", "F1"), Sep, c("About DrawCraft", "help.about")]), + ] +} + +/// Render the menu bar. +pub fn menu_bar(app: &mut DrawcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let mut clicked: Option<(String, Value)> = None; + let tree = menu_tree(); + egui::MenuBar::new().ui(ui, |ui| { + for (i, (title, items)) in tree.iter().enumerate() { + let text = if i == 0 { egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) } else { egui::RichText::new(*title).size(13.0).color(t.text) }; + ui.menu_button(text, |ui| { + ui.set_min_width(230.0); + render_items(app, ui, items, &mut clicked); + }); + } + }); + if let Some((id, p)) = clicked { + invoke(app, &id, p); + } +} + +fn render_items(app: &DrawcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { + let t = Tokens::get(ui.ctx()); + for it in items { + match it { + Item::Sep => { + ui.separator(); + } + Item::Header(h) => { + ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim)); + } + Item::Sub(label, children) => { + ui.menu_button(*label, |ui| { + ui.set_min_width(200.0); + render_items(app, ui, children, clicked); + }); + } + Item::Todo(label, sc) => { + ui.add_enabled_ui(false, |ui| { + ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc)).min_size(egui::vec2(ui.available_width(), 0.0))); + }) + .response + .on_disabled_hover_text("Coming soon — tracked in the parity plan"); + } + Item::Cmd(label, id, p) => { + let en = enabled(app, id); + let label = dynamic_label(app, id, label); + let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() }; + let chk = checked(app, id, p); + let text = match chk { + Some(true) => format!("✓ {label}"), + Some(false) => format!(" {label}"), + None => label, + }; + let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc).min_size(egui::vec2(ui.available_width(), 0.0))); + if r.clicked() { + *clicked = Some((id.to_string(), if p.is_null() { json!({}) } else { p.clone() })); + ui.close(); + } + } + } + } +} + +/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input). +pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) { + // Commands whose menu item opens a dialog in Illustrator. + let dialog = match id { + "object.move" => Some(("move", json!({"dx": "0 pt", "dy": "0 pt"}))), + "object.rotate" => Some(("rotate", json!({"angle": 0}))), + "object.scale" => Some(("scale", json!({"sx": 100, "sy": 100, "uniform": true}))), + "object.reflect" => Some(("reflect", json!({"axis": "vertical"}))), + "object.shear" => Some(("shear", json!({"angle": 0, "axis": "horizontal"}))), + "path.average" => Some(("average", json!({"axis": "both"}))), + "object.path.offsetPath" => Some(("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4}))), + "object.path.simplify" => Some(("simplify", json!({"tolerance": "1 pt"}))), + "object.path.splitIntoGrid" => Some(("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"}))), + _ => None, + }; + if let Some((kind, fields)) = dialog + && p.as_object().is_none_or(|o| o.is_empty()) + { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + return; + } + if let Err(e) = app.run(id, p) { + app.status(e); + } +} + +/// `Cmd+Shift+]` → `⇧⌘]` on macOS, `Ctrl+Shift+]` elsewhere. +pub fn pretty_shortcut(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + if cfg!(target_os = "macos") { + let mut mods = String::new(); + let parts: Vec<&str> = s.split('+').collect(); + let (key, ms) = if s.ends_with("++") { ("+", &parts[..parts.len() - 2]) } else { (*parts.last().unwrap(), &parts[..parts.len() - 1]) }; + for m in ["Ctrl", "Alt", "Shift", "Cmd"] { + if ms.contains(&m) { + mods.push_str(match m { + "Ctrl" => "⌃", + "Alt" => "⌥", + "Shift" => "⇧", + _ => "⌘", + }); + } + } + format!("{mods}{key}") + } else { + s.replace("Cmd", "Ctrl") + } +} + +#[derive(Serialize)] +pub struct MenuEntry { + pub path: Vec, + pub label: String, + pub command: Option, + pub params: Value, + pub enabled: bool, + pub shortcut: String, +} + +/// Flattened menu for `ui.menu.list`. +pub fn menu_entries(app: &DrawcraftApp) -> Vec { + fn walk(app: &DrawcraftApp, path: Vec, items: &[Item], out: &mut Vec) { + for it in items { + match it { + Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), command: Some(id.to_string()), params: p.clone(), enabled: enabled(app, id), shortcut: shortcut_of(id).unwrap_or("").to_string() }), + Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), label: l.to_string(), command: None, params: Value::Null, enabled: false, shortcut: sc.to_string() }), + Item::Sub(l, ch) => { + let mut p = path.clone(); + p.push(l.to_string()); + walk(app, p, ch, out); + } + _ => {} + } + } + } + let mut out = vec![]; + for (title, items) in menu_tree() { + walk(app, vec![title.to_string()], &items, &mut out); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_bound_menu_command_exists() { + fn walk(items: &[Item], bad: &mut Vec) { + for it in items { + match it { + Item::Cmd(_, id, _) => { + if drawcraft_engine::find_command(id).is_none() && !UI_COMMANDS.iter().any(|c| c.0 == *id) && !id.starts_with("object.path.") && *id != "type.createOutlines" { + bad.push(id.to_string()); + } + } + Item::Sub(_, ch) => walk(ch, bad), + _ => {} + } + } + } + let mut bad = vec![]; + for (_, items) in menu_tree() { + walk(&items, &mut bad); + } + assert!(bad.is_empty(), "menu items bound to unknown commands: {bad:?}"); + } + + #[test] + fn shortcut_pretty() { + if cfg!(target_os = "macos") { + assert_eq!(pretty_shortcut("Cmd+Shift+]"), "⇧⌘]"); + assert_eq!(pretty_shortcut("Cmd+Alt+2"), "⌥⌘2"); + } + assert_eq!(pretty_shortcut(""), ""); + } +} diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs new file mode 100644 index 00000000..528fa0c6 --- /dev/null +++ b/crates/ui-egui/src/palette.rs @@ -0,0 +1,55 @@ +//! The command palette: fuzzy search over every command and tool. + +use serde_json::json; + +use crate::theme::Tokens; +use crate::{DrawcraftApp, menus}; + +pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { + if !app.ui.palette_open { + return; + } + let t = Tokens::get(ctx); + let q = app.ui.palette_query.to_lowercase(); + let mut items: Vec<(String, String, String)> = vec![]; // (label, kind/id, shortcut) + for c in drawcraft_engine::command_specs() { + let path = c.menu.join(" › "); + let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) }; + items.push((label, c.id.to_string(), c.shortcut.unwrap_or("").to_string())); + } + for c in menus::UI_COMMANDS { + items.push((c.1.to_string(), c.0.to_string(), c.2.to_string())); + } + for tool in drawcraft_tools::catalog::all_tools() { + items.push((tool.label.to_string(), format!("tool:{}", tool.id), tool.shortcut.unwrap_or("").to_string())); + } + let matches: Vec<&(String, String, String)> = items.iter().filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))).take(14).collect(); + let mut run: Option = None; + egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| { + egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(520.0); + let r = ui.add(egui::TextEdit::singleline(&mut app.ui.palette_query).hint_text("Search commands and tools…").desired_width(500.0)); + r.request_focus(); + ui.add_space(6.0); + for (i, (label, id, sc)) in matches.iter().enumerate() { + let resp = ui.add(egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0)); + if resp.clicked() { + run = Some(id.clone()); + } + } + if ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(first) = matches.first() + { + run = Some(first.1.clone()); + } + }); + }); + if let Some(id) = run { + app.ui.palette_open = false; + if let Some(tool) = id.strip_prefix("tool:") { + app.select_tool(tool); + } else { + menus::invoke(app, &id, json!({})); + } + } +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs new file mode 100644 index 00000000..ab8d8c35 --- /dev/null +++ b/crates/ui-egui/src/panels/layers.rs @@ -0,0 +1,217 @@ +//! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target +//! circles and selection squares; bottom bar with new/delete. + +use std::collections::HashSet; + +use drawcraft_doc::{Node, NodeId, NodeKind}; +use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{DrawcraftApp, icons, widgets}; + +const ROW: f32 = 26.0; + +fn expanded_id() -> egui::Id { + egui::Id::new("layers-expanded") +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let sel: HashSet = st.selection.objects.iter().copied().collect(); + let current = st.active_layer; + let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); + let mut actions: Vec<(String, serde_json::Value)> = vec![]; + let h = ui.available_height() - 34.0; + egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for l in doc.layers.iter().rev() { + row(ui, &doc, l, 0, &sel, current, &mut expanded, &mut actions, &t); + } + }); + ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); + // Bottom bar. + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); + ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); + let n = doc.layers.len(); + ui.painter().text(bar.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, format!("{n} Layer{}", if n == 1 { "" } else { "s" }), egui::FontId::proportional(11.5), t.text_dim); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() { + if app.session.active().is_some_and(|d| !d.selection.is_empty()) { + actions.push(("edit.clear".into(), json!({}))); + } else { + actions.push(("layer.delete".into(), json!({}))); + } + } + if widgets::icon_button(&mut child, "file-plus", "Create New Layer", false, 24.0).clicked() { + actions.push(("layer.new".into(), json!({}))); + } + if widgets::icon_button(&mut child, "plus", "Create New Sublayer", false, 24.0).clicked() { + actions.push(("layer.newSublayer".into(), json!({}))); + } + if widgets::icon_button(&mut child, "frame", "Make/Release Clipping Mask", false, 24.0).clicked() { + actions.push(("object.clippingMask.make".into(), json!({}))); + } + if widgets::icon_button(&mut child, "search", "Locate Object", false, 24.0).clicked() { + // Expand ancestors of the selection. + if let Some(st) = app.session.active() { + let mut ex: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_default(); + for id in &st.selection.objects { + for a in st.doc.ancestry(*id).unwrap_or_default() { + ex.insert(a.0); + } + } + ui.data_mut(|d| d.insert_temp(expanded_id(), ex)); + } + } + for (c, p) in actions { + app.run(&c, p).ok(); + } +} + +#[allow(clippy::too_many_arguments)] +fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet, current: Option, expanded: &mut HashSet, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click()); + let is_sel = sel.contains(&n.id); + let child_sel = !is_sel && n.children().is_some_and(|_| { + let mut any = false; + n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id)); + any + }); + let color = { + let c = doc.layer_color(n.id); + Color32::from_rgb(c[0], c[1], c[2]) + }; + if n.is_layer() && Some(n.id) == current { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); + // Eye and lock columns. + let eye = egui::Rect::from_min_size(r.min, vec2(24.0, ROW)); + let lock = egui::Rect::from_min_size(r.min + vec2(24.0, 0.0), vec2(22.0, ROW)); + ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.border)); + ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.border)); + let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click()); + if n.visible { + icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon); + } + if er.clicked() { + let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; + let key = if n.is_layer() { "id" } else { "ids" }; + let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; + actions.push((cmd.into(), json!({key: idv, "visible": !n.visible}))); + } + let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click()); + if n.locked { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); + } else if lr.hovered() { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); + } + if lr.clicked() { + let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; + let key = if n.is_layer() { "id" } else { "ids" }; + let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; + actions.push((cmd.into(), json!({key: idv, "locked": !n.locked}))); + } + // Layer colour bar. + let mut x = lock.right() + 2.0; + if n.is_layer() { + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(3.0, ROW - 4.0)), 0.0, color); + } + x += 6.0 + depth as f32 * 14.0; + // Disclosure. + let has_children = n.children().is_some_and(|c| !c.is_empty()) && !matches!(n.kind, NodeKind::Compound { .. }); + if has_children { + let open = expanded.contains(&n.id.0); + let dr = egui::Rect::from_min_size(egui::pos2(x, r.top() + 5.0), vec2(14.0, 16.0)); + let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click()); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); + if dresp.clicked() { + if open { + expanded.remove(&n.id.0); + } else { + expanded.insert(n.id.0); + } + } + } + x += 16.0; + // Thumbnail. + let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 3.0), vec2(20.0, 20.0)); + ui.painter().rect_filled(th, 0.0, Color32::WHITE); + ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + thumb(ui, n, th); + x += 26.0; + // Name. + let name = n.display_name(); + let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) }; + ui.painter().with_clip_rect(egui::Rect::from_min_max(egui::pos2(x, r.top()), egui::pos2(r.right() - 44.0, r.bottom()))).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, font, t.text); + // Target circle and selection square. + let tc = egui::pos2(r.right() - 30.0, r.center().y); + let styled = !n.appearance.is_basic() || n.opacity < 1.0; + ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.text_dim)); + if is_sel { + ui.painter().circle_stroke(tc, 3.0, Stroke::new(1.0, t.text_dim)); + } + if styled { + ui.painter().circle_filled(tc, 3.5, t.text_dim); + } + let sq = egui::pos2(r.right() - 12.0, r.center().y); + if is_sel { + ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(8.0, 8.0)), 0.0, color); + } else if child_sel { + ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); + } + let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, ROW)), ui.id().with(("selsq", n.id.0)), Sense::click()); + if sq_resp.clicked() { + if n.is_layer() { + actions.push(("layer.selectAll".into(), json!({"id": n.id.0}))); + } else if ui.input(|i| i.modifiers.shift) { + actions.push(("select.toggle".into(), json!({"id": n.id.0}))); + } else { + actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); + } + } else if resp.clicked() { + if n.is_layer() { + actions.push(("layer.setCurrent".into(), json!({"id": n.id.0}))); + } else { + actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); + } + } + if resp.double_clicked() && n.is_layer() { + // Rename via Layer Options (later: inline edit). + } + if has_children && expanded.contains(&n.id.0) { + for c in n.children().unwrap().iter().rev() { + row(ui, doc, c, depth + 1, sel, current, expanded, actions, t); + } + } +} + +/// Tiny vector thumbnail painted with egui (fast, no raster). +fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { + let Some(b) = n.visual_bounds() else { return }; + let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32; + let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s); + let p = ui.painter().with_clip_rect(r); + let mut count = 0; + n.walk(&mut |c| { + if count > 40 { + return; + } + if let Some(pd) = c.path_data() { + count += 1; + let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| { + let [a, b2, d, _] = cc.to_rgba8(1.0); + Color32::from_rgb(a, b2, d) + }); + if let Some(bb) = pd.bounds() { + let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s)); + p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120))); + } + } + }); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs new file mode 100644 index 00000000..6fec1836 --- /dev/null +++ b/crates/ui-egui/src/panels/mod.rs @@ -0,0 +1,487 @@ +//! Panels. Each panel reads engine state and acts only through `app.run(...)`. + +pub mod layers; +pub mod properties; + +use drawcraft_color::{BlendMode, Color, Paint}; +use drawcraft_doc::{AppearanceItem, Node}; +use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::widgets::{self, dim_label, paint_chip}; +use crate::{DrawcraftApp, icons}; + +/// The first selected node (cloned), if any. +pub fn first_selected(app: &DrawcraftApp) -> Option { + let st = app.session.active()?; + st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned() +} + +pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { + match id { + "swatches" => swatches(app, ui), + "color" => color(app, ui), + "colorGuide" => color_guide(app, ui), + "stroke" => stroke(app, ui), + "transparency" => transparency(app, ui), + "appearance" => appearance(app, ui), + "graphicStyles" => graphic_styles(app, ui), + "align" => align(app, ui), + "pathfinder" => pathfinder(app, ui), + "transform" => properties::transform_section(app, ui), + "history" => history(app, ui), + "info" => info(app, ui), + "artboards" => artboards(app, ui), + "gradient" => gradient(app, ui), + "character" | "paragraph" => properties::type_sections(app, ui), + "navigator" => navigator(app, ui), + _ => { + dim_label(ui, "This panel is on the roadmap (see the parity plan)."); + } + } +} + +pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(20.0); + ui.vertical_centered(|ui| { + icons::icon(ui, "library", 40.0, t.text_dim); + ui.add_space(8.0); + ui.label(egui::RichText::new("Local Libraries").size(14.0).color(t.text)); + dim_label(ui, "Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required."); + }); +} + +fn paint_target(app: &DrawcraftApp) -> &'static str { + if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" } +} + +pub fn swatches(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + dim_label(ui, "Open a document to see its swatches."); + return; + }; + let mut all: Vec<(String, Paint)> = st.doc.swatches.iter().map(|s| (s.name.clone(), s.paint.clone())).collect(); + for g in &st.doc.swatch_groups { + for s in &g.swatches { + all.push((s.name.clone(), s.paint.clone())); + } + } + let target = paint_target(app); + let mut chosen = None; + ui.horizontal(|ui| { + let (f, s) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone()); + let (a, b, _, _) = widgets::fill_stroke_proxy(ui, &f, &s, app.session.fill_active, 34.0); + if a { + app.session.fill_active = true; + } + if b { + app.session.fill_active = false; + } + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for (name, paint) in &all { + let (r, resp) = ui.allocate_exact_size(vec2(19.0, 19.0), Sense::click()); + paint_chip(ui, r, paint); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside); + if resp.on_hover_text(name).clicked() { + chosen = Some(name.clone()); + } + } + }); + if let Some(name) = chosen { + app.run(target, json!({"swatch": name})).ok(); + } + widgets::divider(ui); + ui.horizontal(|ui| { + if widgets::icon_button(ui, "plus", "New Swatch", false, 22.0).clicked() { + app.run("swatch.new", json!({})).ok(); + } + }); +} + +pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) { + let current = match first_selected(app) { + Some(n) => if app.session.fill_active { n.appearance.fill_paint() } else { n.appearance.stroke_paint() }, + None => if app.session.fill_active { app.session.paint.fill.clone() } else { app.session.paint.stroke.clone() }, + }; + let c = current.color().unwrap_or(Color::BLACK); + let mut rgb = c.to_rgba8(1.0); + let mut changed = false; + for (i, lbl) in ["R", "G", "B"].iter().enumerate() { + ui.horizontal(|ui| { + ui.label(*lbl); + let mut v = rgb[i] as i32; + if ui.add(egui::Slider::new(&mut v, 0..=255)).changed() { + rgb[i] = v as u8; + changed = true; + } + }); + } + let mut hex = c.to_hex(); + ui.horizontal(|ui| { + ui.label("#"); + if ui.add(egui::TextEdit::singleline(&mut hex).desired_width(80.0)).lost_focus() + && let Some(nc) = Color::from_hex(&hex) + { + rgb = nc.to_rgba8(1.0); + changed = true; + } + }); + // Spectrum ramp. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click_and_drag()); + let n = 64; + for i in 0..n { + for j in 0..4 { + let h = i as f32 / n as f32 * 360.0; + let v = 1.0 - j as f32 / 4.0 * 0.8; + let [cr, cg, cb, _] = Color::from_hsb(h, 1.0, v).to_rgba8(1.0); + let cell = egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0)); + ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb(cr, cg, cb)); + } + } + if (resp.clicked() || resp.dragged()) + && let Some(p) = resp.interact_pointer_pos() + { + let h = ((p.x - r.left()) / r.width()).clamp(0.0, 1.0) * 360.0; + let v = 1.0 - ((p.y - r.top()) / r.height()).clamp(0.0, 1.0) * 0.8; + rgb = Color::from_hsb(h, 1.0, v).to_rgba8(1.0); + changed = true; + } + if changed { + let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]); + app.run(paint_target(app), json!({"color": hex})).ok(); + } +} + +fn color_guide(app: &mut DrawcraftApp, ui: &mut Ui) { + let base = first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1)); + for h in drawcraft_color::harmony::Harmony::ALL { + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(h.label()).size(11.0))); + for c in h.apply(base) { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + paint_chip(ui, r, &Paint::solid(c)); + if resp.clicked() { + app.run(paint_target(app), json!({"color": c.to_hex()})).ok(); + } + } + }); + } +} + +pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = first_selected(app); + let st = n.as_ref().and_then(|n| n.appearance.stroke().cloned()); + let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); + ui.horizontal(|ui| { + dim_label(ui, "Weight:"); + if let Some(w) = widgets::num_field(ui, "stroke-w", Some(weight), drawcraft_doc::Unit::Points, 80.0) { + app.run("stroke.set", json!({"weight": w})).ok(); + } + }); + let cap = st.as_ref().map(|s| s.cap).unwrap_or_default(); + let join = st.as_ref().map(|s| s.join).unwrap_or_default(); + let align = st.as_ref().map(|s| s.align).unwrap_or_default(); + ui.horizontal(|ui| { + dim_label(ui, "Cap:"); + for (v, lbl, name) in [(drawcraft_doc::LineCap::Butt, "Butt", "butt"), (drawcraft_doc::LineCap::Round, "Round", "round"), (drawcraft_doc::LineCap::Square, "Projecting", "square")] { + if ui.selectable_label(cap == v, lbl).clicked() { + app.run("stroke.set", json!({"cap": name})).ok(); + } + } + }); + ui.horizontal(|ui| { + dim_label(ui, "Corner:"); + for (v, lbl, name) in [(drawcraft_doc::LineJoin::Miter, "Miter", "miter"), (drawcraft_doc::LineJoin::Round, "Round", "round"), (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel")] { + if ui.selectable_label(join == v, lbl).clicked() { + app.run("stroke.set", json!({"join": name})).ok(); + } + } + }); + ui.horizontal(|ui| { + dim_label(ui, "Align:"); + for (v, lbl, name) in [(drawcraft_doc::StrokeAlign::Center, "Center", "center"), (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside")] { + if ui.selectable_label(align == v, lbl).clicked() { + app.run("stroke.set", json!({"align": name})).ok(); + } + } + }); + let mut dashed = st.as_ref().is_some_and(|s| s.dash.is_some()); + if ui.checkbox(&mut dashed, "Dashed Line").changed() { + app.run("stroke.set", if dashed { json!({"dash": [12, 6]}) } else { json!({"dash": null}) }).ok(); + } + ui.horizontal(|ui| { + dim_label(ui, "Arrowheads:"); + let end = st.as_ref().and_then(|s| s.end_arrow).map(|a| format!("{a:?}")).unwrap_or("None".into()); + let opts = ["None", "Arrow", "Triangle", "Circle", "Square", "Diamond", "Bar"]; + if let Some(i) = widgets::dropdown(ui, "arrow-end", &end, &opts, 110.0) { + let v = if i == 0 { json!(null) } else { json!(opts[i]) }; + app.run("stroke.set", json!({"endArrow": v})).ok(); + } + }); + ui.horizontal(|ui| { + dim_label(ui, "Profile:"); + let opts = ["Uniform", "Width Profile 1 (lens)", "Taper Start", "Taper End"]; + let ids = ["uniform", "lens", "taperStart", "taperEnd"]; + if let Some(i) = widgets::dropdown(ui, "profile", "Uniform", &opts, 150.0) { + app.run("stroke.set", json!({"profile": ids[i]})).ok(); + } + }); +} + +fn blend_dropdown(app: &mut DrawcraftApp, ui: &mut Ui, current: BlendMode) { + let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "blend", current.label(), &labels, 130.0) { + app.run("object.setProps", json!({"blend": labels[i]})).ok(); + } +} + +pub fn transparency(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = first_selected(app); + let (blend, op) = n.as_ref().map(|n| (n.blend, n.opacity)).unwrap_or((BlendMode::Normal, 1.0)); + ui.horizontal(|ui| { + blend_dropdown(app, ui, blend); + dim_label(ui, "Opacity:"); + if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 60.0) { + app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); + } + }); + let mut o = op * 100.0; + if ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)).drag_stopped() { + app.run("object.setProps", json!({"opacity": o / 100.0})).ok(); + } +} + +pub fn appearance(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(n) = first_selected(app) else { + dim_label(ui, "No Selection"); + return; + }; + ui.label(egui::RichText::new(n.kind_label()).strong()); + let items: Vec<(usize, AppearanceItem)> = n.appearance.items.iter().cloned().enumerate().rev().collect(); + for (i, it) in items { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.divider)); + let (label, paint, extra) = match &it { + AppearanceItem::Fill(f) => ("Fill:", f.paint.clone(), String::new()), + AppearanceItem::Stroke(s) => ("Stroke:", s.paint.clone(), format!("{} pt", s.width)), + }; + ui.painter().text(r.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); + let chip = egui::Rect::from_min_size(r.left_center() + vec2(74.0, -8.0), vec2(16.0, 16.0)); + paint_chip(ui, chip, &paint); + ui.painter().text(r.left_center() + vec2(98.0, 0.0), egui::Align2::LEFT_CENTER, extra, egui::FontId::proportional(12.0), t.text_dim); + let del = egui::Rect::from_center_size(r.right_center() - vec2(12.0, 0.0), vec2(14.0, 14.0)); + let dr = ui.interact(del, ui.id().with(("ap-del", i)), Sense::click()); + icons::paint(ui, "trash-2", del, if dr.hovered() { t.text } else { t.text_disabled }); + if dr.clicked() { + app.run("appearance.removeItem", json!({"index": i})).ok(); + } + icons::paint(ui, "eye", egui::Rect::from_center_size(r.left_center() + vec2(9.0, 0.0), vec2(13.0, 13.0)), t.icon); + } + ui.horizontal(|ui| { + dim_label(ui, "Opacity:"); + dim_label(ui, &format!("{:.0}% {}", n.opacity * 100.0, n.blend.label())); + }); + widgets::divider(ui); + ui.horizontal(|ui| { + if widgets::flat_button(ui, "+ Stroke", 70.0).clicked() { + app.run("appearance.addStroke", json!({})).ok(); + } + if widgets::flat_button(ui, "+ Fill", 60.0).clicked() { + app.run("appearance.addFill", json!({})).ok(); + } + if widgets::flat_button(ui, "Clear", 50.0).clicked() { + app.run("appearance.clear", json!({})).ok(); + } + }); +} + +fn graphic_styles(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let styles: Vec<(String, drawcraft_doc::Appearance)> = app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + for (name, ap) in styles { + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + let inner = r.shrink(8.0); + paint_chip(ui, inner, &ap.fill_paint()); + if let Some(s) = ap.stroke() { + let col = s.paint.color().map(|c| { + let [a, b, cc, _] = c.to_rgba8(1.0); + egui::Color32::from_rgb(a, b, cc) + }); + if let Some(col) = col { + ui.painter().rect_stroke(inner, 0.0, Stroke::new((s.width as f32).clamp(1.0, 4.0), col), StrokeKind::Middle); + } + } + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.border }), StrokeKind::Inside); + if resp.on_hover_text(&name).clicked() { + app.run("graphicStyle.apply", json!({"name": name})).ok(); + } + } + }); +} + +pub fn align(app: &mut DrawcraftApp, ui: &mut Ui) { + dim_label(ui, "Align Objects:"); + ui.horizontal(|ui| { + for (icon, tip, p) in [ + ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), + ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), + ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), + ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), + ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), + ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), + ] { + if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { + app.run("object.align", p).ok(); + } + } + }); + dim_label(ui, "Distribute Objects:"); + ui.horizontal(|ui| { + for (icon, tip, p) in [ + ("align-vertical-justify-start", "Vertical Distribute Top", json!({"vertical": "top"})), + ("align-vertical-justify-center", "Vertical Distribute Center", json!({"vertical": "center"})), + ("align-vertical-justify-end", "Vertical Distribute Bottom", json!({"vertical": "bottom"})), + ("align-horizontal-justify-start", "Horizontal Distribute Left", json!({"horizontal": "left"})), + ("align-horizontal-justify-center", "Horizontal Distribute Center", json!({"horizontal": "center"})), + ("align-horizontal-justify-end", "Horizontal Distribute Right", json!({"horizontal": "right"})), + ] { + if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { + app.run("object.distribute", p).ok(); + } + } + }); + dim_label(ui, "Distribute Spacing:"); + ui.horizontal(|ui| { + if widgets::icon_button(ui, "arrow-up-down", "Vertical Distribute Space", false, 28.0).clicked() { + app.run("object.distributeSpacing", json!({"axis": "vertical"})).ok(); + } + if widgets::icon_button(ui, "arrow-left-right", "Horizontal Distribute Space", false, 28.0).clicked() { + app.run("object.distributeSpacing", json!({"axis": "horizontal"})).ok(); + } + }); +} + +pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) { + dim_label(ui, "Shape Modes:"); + ui.horizontal(|ui| { + for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] { + if widgets::icon_button(ui, icon, tip, false, 30.0).clicked() { + app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); + } + } + }); + dim_label(ui, "Pathfinders:"); + ui.horizontal_wrapped(|ui| { + for (tip, op) in [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] { + if widgets::flat_button(ui, tip, 72.0).clicked() { + app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); + } + } + }); +} + +fn history(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let undo: Vec = st.history.undo.iter().map(|h| h.label.clone()).collect(); + let redo: Vec = st.history.redo.iter().rev().map(|h| h.label.clone()).collect(); + let n_undo = undo.len(); + let mut steps: Option = None; + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "Open", egui::FontId::proportional(12.0), t.text_dim); + if resp.clicked() { + steps = Some(-(n_undo as i64)); + } + for (i, l) in undo.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if i + 1 == n_undo { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } + ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text); + if resp.clicked() { + steps = Some(i as i64 + 1 - n_undo as i64); + } + } + for (i, l) in redo.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, l, egui::FontId::proportional(12.0), t.text_disabled); + if resp.clicked() { + steps = Some(i as i64 + 1); + } + } + }); + if let Some(s) = steps { + let (cmd, n) = if s < 0 { ("edit.undo", -s) } else { ("edit.redo", s) }; + for _ in 0..n { + app.run(cmd, json!({})).ok(); + } + } +} + +fn info(app: &mut DrawcraftApp, ui: &mut Ui) { + let p = app.hover_doc.unwrap_or_default(); + ui.monospace(format!("X: {:>9.2} pt Y: {:>9.2} pt", p.x, p.y)); + if let Some(st) = app.session.active() + && let Some(b) = st.doc.bounds_of(&st.selection.objects, false) + { + ui.monospace(format!("W: {:>9.2} pt H: {:>9.2} pt", b.width(), b.height())); + } + if let Some(n) = first_selected(app) { + ui.label(format!("Fill: {}", n.appearance.fill_paint().label())); + ui.label(format!("Stroke: {}", n.appearance.stroke_paint().label())); + } +} + +fn artboards(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let abs: Vec<(usize, String)> = app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default(); + for (i, name) in abs { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{}", i + 1), egui::FontId::proportional(12.0), t.text_dim); + ui.painter().text(r.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); + } + ui.horizontal(|ui| { + if widgets::icon_button(ui, "plus", "New Artboard", false, 22.0).clicked() { + app.run("artboard.new", json!({})).ok(); + } + }); +} + +fn gradient(app: &mut DrawcraftApp, ui: &mut Ui) { + let n = first_selected(app); + let paint = n.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or(Paint::None); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); + let preview = if matches!(paint, Paint::Gradient(_)) { paint.clone() } else { Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) }; + paint_chip(ui, r, &preview); + ui.horizontal(|ui| { + dim_label(ui, "Type:"); + if widgets::flat_button(ui, "Linear", 60.0).clicked() { + app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).ok(); + } + if widgets::flat_button(ui, "Radial", 60.0).clicked() { + app.run("paint.setFill", json!({"gradient": {"kind": "radial"}})).ok(); + } + }); +} + +fn navigator(app: &mut DrawcraftApp, ui: &mut Ui) { + let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); + let mut z = zoom * 100.0; + if ui.add(egui::Slider::new(&mut z, 3.13..=6400.0).logarithmic(true).suffix("%")).changed() { + app.run("view.setZoom", json!({"zoom": z})).ok(); + } + let _ = CornerRadius::ZERO; +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs new file mode 100644 index 00000000..259bda99 --- /dev/null +++ b/crates/ui-egui/src/panels/properties.rs @@ -0,0 +1,354 @@ +//! The Properties panel: context-sensitive sections like Illustrator's. + +use drawcraft_doc::{NodeKind, Unit}; +use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::json; + +use super::first_selected; +use crate::theme::Tokens; +use crate::widgets::{self, dim_label, divider, section_header}; +use crate::{DrawcraftApp, icons}; + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + dim_label(ui, "No document open"); + return; + }; + let n_sel = st.selection.len(); + let first = first_selected(app); + let label = match (&first, n_sel) { + (None, _) => "Document".to_string(), + (_, n) if n > 1 => format!("{n} Objects"), + (Some(n), _) => n.kind_label().to_string(), + }; + ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); + ui.add_space(4.0); + if first.is_none() { + document_sections(app, ui); + return; + } + transform_section(app, ui); + divider(ui); + if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) { + type_sections(app, ui); + divider(ui); + } + appearance_section(app, ui); + divider(ui); + section_header(ui, "Align"); + ui.horizontal(|ui| { + for (icon, tip, p) in [ + ("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})), + ("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})), + ("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})), + ("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})), + ("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})), + ("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})), + ] { + if widgets::icon_button(ui, icon, tip, false, 26.0).clicked() { + let mut p = p; + if n_sel == 1 { + p["to"] = json!("artboard"); + } + app.run("object.align", p).ok(); + } + } + }); + if n_sel > 1 { + divider(ui); + section_header(ui, "Pathfinder"); + ui.horizontal(|ui| { + for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] { + if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() { + app.run(&format!("object.pathfinder.{op}"), json!({})).ok(); + } + } + if widgets::icon_button(ui, "ellipsis", "More Pathfinder options", false, 28.0).clicked() { + app.ui.open_panel = Some("pathfinder".into()); + } + }); + } + divider(ui); + section_header(ui, "Quick Actions"); + let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. })); + let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(_), .. })); + let mut actions: Vec<(&str, &str)> = vec![]; + if n_sel > 1 { + actions.push(("Group", "object.group")); + actions.push(("Make Clipping Mask", "object.clippingMask.make")); + } + if is_group { + actions.push(("Ungroup", "object.ungroup")); + actions.push(("Isolate Group", "object.isolate")); + } + if is_live { + actions.push(("Expand Shape", "object.expandShape")); + } + actions.push(("Offset Path", "object.path.offsetPath")); + actions.push(("Simplify", "object.path.simplify")); + actions.push(("Arrange: Bring to Front", "object.arrange.bringToFront")); + actions.push(("Lock", "object.lock")); + let w = (ui.available_width() - 6.0) / 2.0; + for pair in actions.chunks(2) { + ui.horizontal(|ui| { + for (label, id) in pair { + if widgets::flat_button(ui, label, w).clicked() { + crate::menus::invoke(app, id, json!({})); + } + } + }); + } +} + +fn document_sections(app: &mut DrawcraftApp, ui: &mut Ui) { + let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); + section_header(ui, "Document"); + ui.horizontal(|ui| { + dim_label(ui, "Units"); + let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "units", units.label(), &labels, 140.0) { + app.run("document.setUnits", json!({"units": labels[i]})).ok(); + } + }); + let w = (ui.available_width() - 6.0) / 2.0; + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Document Setup", w).clicked() { + app.run("file.documentSetup", json!({})).ok(); + } + if widgets::flat_button(ui, "Edit Artboards", w).clicked() { + app.select_tool("artboard"); + } + }); + divider(ui); + section_header(ui, "Rulers & Grids"); + ui.horizontal(|ui| { + let v = app.ui.view.clone(); + if widgets::icon_button(ui, "ruler", "Show Rulers (⌘R)", v.rulers, 26.0).clicked() { + app.run("view.rulers", json!({})).ok(); + } + if widgets::icon_button(ui, "grid-3x3", "Show Grid (⌘')", v.grid, 26.0).clicked() { + app.run("view.grid", json!({})).ok(); + } + if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", v.transparency_grid, 26.0).clicked() { + app.run("view.transparencyGrid", json!({})).ok(); + } + }); + divider(ui); + section_header(ui, "Guides"); + ui.horizontal(|ui| { + let v = app.ui.view.clone(); + if widgets::icon_button(ui, "layout-grid", "Show Guides", v.guides, 26.0).clicked() { + app.run("view.guides", json!({})).ok(); + } + if widgets::icon_button(ui, "sparkles", "Smart Guides (⌘U)", v.smart_guides, 26.0).clicked() { + app.run("view.smartGuides", json!({})).ok(); + } + }); + divider(ui); + section_header(ui, "Snap Options"); + let mut sp = app.ui.view.snap_to_point; + if ui.checkbox(&mut sp, "Snap to Point").changed() { + app.ui.view.snap_to_point = sp; + } + let mut sg = app.ui.view.snap_to_grid; + if ui.checkbox(&mut sg, "Snap to Grid").changed() { + app.ui.view.snap_to_grid = sg; + } + divider(ui); + section_header(ui, "Preferences"); + ui.horizontal(|ui| { + dim_label(ui, "Keyboard Increment"); + if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, 80.0) { + app.session.prefs.keyboard_increment = v.max(0.001); + } + }); + let mut ss = app.session.prefs.scale_strokes; + if ui.checkbox(&mut ss, "Scale Strokes & Effects").changed() { + app.session.prefs.scale_strokes = ss; + } + divider(ui); + section_header(ui, "Quick Actions"); + let w = (ui.available_width() - 6.0) / 2.0; + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Document Setup", w).clicked() { + app.run("file.documentSetup", json!({})).ok(); + } + if widgets::flat_button(ui, "Preferences", w).clicked() { + app.run("edit.preferences", json!({})).ok(); + } + }); +} + +pub fn transform_section(app: &mut DrawcraftApp, ui: &mut Ui) { + let Some(st) = app.session.active() else { return }; + let units = st.doc.units; + let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { + dim_label(ui, "No Selection"); + return; + }; + let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); + let rp = drawcraft_geom::reference_point(b, refi); + section_header(ui, "Transform"); + ui.horizontal(|ui| { + if let Some(i) = widgets::reference_point(ui, refi) { + ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); + } + ui.add_space(6.0); + egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 5.0]).show(ui, |ui| { + let fw = 78.0; + dim_label(ui, "X:"); + if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) { + app.run("object.setBounds", json!({"x": v, "reference": refi})).ok(); + } + dim_label(ui, "W:"); + if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) { + app.run("object.setBounds", json!({"width": v, "reference": refi})).ok(); + } + ui.end_row(); + dim_label(ui, "Y:"); + if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y), units, fw) { + app.run("object.setBounds", json!({"y": v, "reference": refi})).ok(); + } + dim_label(ui, "H:"); + if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) { + app.run("object.setBounds", json!({"height": v, "reference": refi})).ok(); + } + ui.end_row(); + }); + }); + ui.horizontal(|ui| { + icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon); + if let Some(a) = widgets::plain_field(ui, "rot", 0.0, "°", 2, 70.0) { + app.run("object.rotate", json!({"angle": a})).ok(); + } + ui.add_space(10.0); + if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() { + app.run("object.reflect", json!({"axis": "vertical"})).ok(); + } + if widgets::icon_button(ui, "flip-vertical-2", "Flip Along Vertical Axis", false, 24.0).clicked() { + app.run("object.reflect", json!({"axis": "horizontal"})).ok(); + } + }); + // Live shape properties. + if let Some(n) = first_selected(app) + && let NodeKind::Path { live: Some(live), .. } = &n.kind + { + match live { + drawcraft_doc::LiveShape::Rectangle { radii, .. } => { + ui.horizontal(|ui| { + dim_label(ui, "Corner Radius:"); + if let Some(r) = widgets::num_field(ui, "radius", Some(radii[0]), units, 80.0) { + app.run("object.setLiveShape", json!({"radius": r})).ok(); + } + }); + } + drawcraft_doc::LiveShape::Polygon { sides, .. } => { + ui.horizontal(|ui| { + dim_label(ui, "Sides:"); + if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, 60.0) { + app.run("object.setLiveShape", json!({"sides": s as u32})).ok(); + } + }); + } + _ => {} + } + } +} + +fn appearance_section(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(n) = first_selected(app) else { return }; + section_header(ui, "Appearance"); + let (fill, stroke, weight) = if let NodeKind::Text(tx) = &n.kind { + let s = tx.first_style(); + (s.fill.clone(), s.stroke.clone(), s.stroke_width) + } else { + (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0)) + }; + for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + widgets::paint_chip(ui, r.shrink(2.0), &paint); + if !is_fill { + ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel); + } + ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); + if resp.clicked() { + app.session.fill_active = is_fill; + app.ui.open_panel = Some("swatches".into()); + } + ui.label(egui::RichText::new(label).size(12.0)); + if !is_fill { + ui.add_space(8.0); + if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) { + app.run("stroke.set", json!({"weight": w})).ok(); + } + if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() { + app.ui.open_panel = Some("stroke".into()); + } + } + }); + } + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Opacity").size(12.0)); + ui.add_space(8.0); + if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) { + app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok(); + } + if widgets::icon_button(ui, "ellipsis", "Transparency", false, 22.0).clicked() { + app.ui.open_panel = Some("transparency".into()); + } + }); + ui.horizontal(|ui| { + let r = widgets::flat_button(ui, "fx", 34.0); + if r.on_hover_text("Effects").clicked() { + app.ui.open_panel = Some("appearance".into()); + } + if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() { + app.ui.open_panel = Some("appearance".into()); + } + }); +} + +pub fn type_sections(app: &mut DrawcraftApp, ui: &mut Ui) { + let Some(n) = first_selected(app) else { + dim_label(ui, "Select a text object"); + return; + }; + let NodeKind::Text(tx) = &n.kind else { + dim_label(ui, "Select a text object"); + return; + }; + let s = tx.first_style(); + section_header(ui, "Character"); + let fams = drawcraft_text::FontDb::global().families(); + let names: Vec<&str> = fams.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "font", &s.font_family, &names, ui.available_width() - 4.0) { + app.run("text.setStyle", json!({"font": names[i]})).ok(); + } + ui.horizontal(|ui| { + dim_label(ui, "Size"); + if let Some(v) = widgets::num_field(ui, "fsize", Some(s.size), Unit::Points, 70.0) { + app.run("text.setStyle", json!({"size": v})).ok(); + } + dim_label(ui, "Leading"); + if let Some(v) = widgets::num_field(ui, "lead", Some(s.effective_leading()), Unit::Points, 70.0) { + app.run("text.setStyle", json!({"leading": v})).ok(); + } + }); + ui.horizontal(|ui| { + dim_label(ui, "Tracking"); + if let Some(v) = widgets::plain_field(ui, "track", s.tracking, "", 0, 60.0) { + app.run("text.setStyle", json!({"tracking": v})).ok(); + } + }); + section_header(ui, "Paragraph"); + ui.horizontal(|ui| { + for (icon, j) in [("align-start-vertical", "left"), ("align-center-vertical", "center"), ("align-end-vertical", "right")] { + if widgets::icon_button(ui, icon, j, false, 24.0).clicked() { + app.run("text.setStyle", json!({"justify": j})).ok(); + } + } + }); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs new file mode 100644 index 00000000..5fbaddf0 --- /dev/null +++ b/crates/ui-egui/src/shortcuts.rs @@ -0,0 +1,208 @@ +//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows, +//! and tool keys (Enter/Esc/↑/↓ while drawing). + +use drawcraft_tools::{Mods, ToolKey}; +use egui::{Key, KeyboardShortcut, Modifiers}; +use serde_json::json; + +use crate::DrawcraftApp; + +/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. +pub fn parse(s: &str) -> Option { + if s.is_empty() { + return None; + } + let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').map(|(m, k)| (m, k)).unwrap_or(("", s)) }; + let mut m = Modifiers::NONE; + for part in mods_part.split('+').filter(|p| !p.is_empty()) { + match part { + "Cmd" => m = m | Modifiers::COMMAND, + "Shift" => m = m | Modifiers::SHIFT, + "Alt" => m = m | Modifiers::ALT, + "Ctrl" => m = m | Modifiers::CTRL, + _ => return None, + } + } + let key = match key_part { + "]" => Key::CloseBracket, + "[" => Key::OpenBracket, + ";" => Key::Semicolon, + "'" => Key::Quote, + "/" => Key::Slash, + "\\" => Key::Backslash, + "=" => Key::Equals, + "-" => Key::Minus, + "+" => Key::Plus, + "Delete" => Key::Delete, + "Backspace" => Key::Backspace, + "Tab" => Key::Tab, + "~" => Key::Backtick, + k => Key::from_name(k)?, + }; + Some(KeyboardShortcut::new(m, key)) +} + +fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> { + let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![]; + for c in drawcraft_engine::command_specs() { + if let Some(sc) = c.shortcut.and_then(parse) { + v.push((sc, c.id)); + } + } + for c in crate::menus::UI_COMMANDS { + if let Some(sc) = parse(c.2) { + v.push((sc, c.0)); + } + } + // Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z. + v.sort_by_key(|(sc, _)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)); + v +} + +pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { + if app.ui.dialog.is_some() || app.ui.palette_open { + if ctx.input(|i| i.key_pressed(Key::Escape)) { + app.ui.dialog = None; + app.ui.palette_open = false; + } + return; + } + let typing = ctx.egui_wants_keyboard_input(); + let view = app.view_info(); + // Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging). + let busy = app.session.tool_busy(); + for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] { + if !typing && ctx.input(|i| i.key_pressed(k)) { + let _ = app.session.tool_key(tk, Mods::default(), view); + if k == Key::Escape && !busy { + if app.session.active().is_some_and(|d| d.isolation.is_some()) { + let _ = app.run("object.exitIsolation", json!({})); + } else if app.ui.flyout.is_some() { + app.ui.flyout = None; + } + } + } + } + if typing { + return; + } + if busy { + for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] { + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { + let _ = app.session.tool_key(tk, Mods::default(), view); + } + } + return; + } + // Command shortcuts. + let mut fire: Option<&'static str> = None; + for (sc, id) in all_shortcuts() { + // Letter / slash keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts, X, D, /). + let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl); + if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) { + continue; + } + if ctx.input_mut(|i| i.consume_shortcut(&sc)) { + fire = Some(id); + break; + } + } + if let Some(id) = fire { + crate::menus::invoke(app, id, json!({})); + return; + } + // Arrow nudges (Shift = ×10, Alt = copy). + let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; + for (k, dx, dy) in arrows { + let m = ctx.input(|i| i.modifiers); + if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) { + let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt})); + } + } + // Delete / Backspace clear the selection. + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) { + let _ = app.run("edit.clear", json!({})); + } + // Single-key tool shortcuts (no Cmd/Ctrl/Alt). + let events: Vec<(String, Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) => Some((t.clone(), i.modifiers)), + _ => None, + }) + .collect() + }); + for (text, m) in events { + if m.command || m.alt || m.ctrl { + continue; + } + let upper = text.to_uppercase(); + let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() }; + match key.as_str() { + "X" => { + let _ = app.run("paint.toggleActive", json!({})); + continue; + } + "Shift+X" => { + let _ = app.run("paint.swap", json!({})); + continue; + } + "D" => { + let _ = app.run("paint.default", json!({})); + continue; + } + "/" => { + let _ = app.run("paint.none", json!({})); + continue; + } + "F" => { + let _ = app.run("view.screenMode", json!({})); + continue; + } + "Shift+F" => { + let _ = app.run("view.presentation", json!({})); + continue; + } + _ => {} + } + if let Some(t) = drawcraft_tools::catalog::tool_for_shortcut(&key).or_else(|| drawcraft_tools::catalog::tool_for_shortcut(&text)) { + app.select_tool(t.id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses() { + let s = parse("Cmd+Shift+]").unwrap(); + assert_eq!(s.logical_key, Key::CloseBracket); + assert!(s.modifiers.shift && s.modifiers.command); + assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals); + assert_eq!(parse("Cmd+Alt+2").unwrap().logical_key, Key::Num2); + assert_eq!(parse("F12").unwrap().logical_key, Key::F12); + assert!(parse("").is_none()); + } + + #[test] + fn all_registered_shortcuts_parse() { + for c in drawcraft_engine::command_specs() { + if let Some(s) = c.shortcut + && s != "D" + && s != "X" + && s != "Shift+X" + && s != "/" + { + assert!(parse(s).is_some(), "{} has unparsable shortcut {s}", c.id); + } + } + for c in crate::menus::UI_COMMANDS { + if !c.2.is_empty() && c.2 != "F" && c.2 != "Shift+F" { + assert!(parse(c.2).is_some(), "{} has unparsable shortcut {}", c.0, c.2); + } + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 00000000..2f6472c9 --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,213 @@ +//! Serializable UI state (read and driven by the control channel). + +use drawcraft_geom::Point; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::theme::Brightness; + +/// Per-document view (camera). +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct View { + /// Screen points per document point (1.0 = 100%). + pub zoom: f64, + /// Document point at the centre of the canvas. + pub center: Point, + /// False until the view was fitted to the artboard on first display. + pub fitted: bool, + /// View rotation in degrees (Rotate View tool). + pub rotation: f64, +} + +impl Default for View { + fn default() -> Self { + Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0 } + } +} + +/// Illustrator's preset zoom stops, in percent. +pub const ZOOM_STOPS: [f64; 26] = [ + 3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, 4800.0, 6400.0, 12800.0, 25600.0, 64000.0, +]; + +pub fn next_zoom(z: f64, up: bool) -> f64 { + let pct = z * 100.0; + let stop = if up { ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0) } else { ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13) }; + stop / 100.0 +} + +/// Format like Illustrator's tab/zoom field: up to 2 decimals, no trailing zeros. +pub fn zoom_label(z: f64) -> String { + let s = format!("{:.2}", z * 100.0); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("{s}%") +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum DockTab { + #[default] + Properties, + Layers, + Libraries, +} + +/// Panels that live as collapsed icons in the dock (Essentials Classic). +pub const ICON_PANELS: &[(&str, &str, &str)] = &[ + ("color", "Color", "palette"), + ("colorGuide", "Color Guide", "dc-color-guide"), + ("swatches", "Swatches", "swatch-book"), + ("brushes", "Brushes", "paintbrush"), + ("symbols", "Symbols", "spray-can"), + ("stroke", "Stroke", "dc-stroke"), + ("gradient", "Gradient", "dc-gradient"), + ("transparency", "Transparency", "dc-transparency"), + ("appearance", "Appearance", "dc-appearance"), + ("graphicStyles", "Graphic Styles", "dc-graphic-styles"), + ("artboards", "Artboards", "dc-artboards"), + ("transform", "Transform", "dc-transform-panel"), + ("align", "Align", "dc-align"), + ("pathfinder", "Pathfinder", "dc-pathfinder"), + ("character", "Character", "type"), + ("paragraph", "Paragraph", "pilcrow"), + ("history", "History", "history"), + ("info", "Info", "info"), + ("navigator", "Navigator", "map"), +]; + +/// Groups of icon panels separated by dividers in the collapsed column. +pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ + &["color", "colorGuide"], + &["swatches", "brushes", "symbols"], + &["stroke", "gradient", "transparency"], + &["appearance", "graphicStyles"], + &["artboards"], + &["transform", "align", "pathfinder"], + &["character", "paragraph"], + &["history", "info", "navigator"], +]; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ViewFlags { + pub rulers: bool, + pub grid: bool, + pub guides: bool, + pub smart_guides: bool, + pub bounding_box: bool, + pub outline: bool, + pub pixel_preview: bool, + pub snap_to_grid: bool, + pub snap_to_point: bool, + pub artboards: bool, + pub transparency_grid: bool, + pub edges: bool, +} + +impl Default for ViewFlags { + fn default() -> Self { + Self { rulers: true, grid: false, guides: true, smart_guides: true, bounding_box: true, outline: false, pixel_preview: false, snap_to_grid: false, snap_to_point: true, artboards: true, transparency_grid: false, edges: true } + } +} + +/// An open modal dialog. `fields` are string-valued so agents can set them uniformly. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Dialog { + pub kind: String, + pub fields: serde_json::Map, +} + +impl Dialog { + pub fn new(kind: &str, fields: Value) -> Self { + Self { kind: kind.to_string(), fields: fields.as_object().cloned().unwrap_or_default() } + } + pub fn f64(&self, k: &str, d: f64) -> f64 { + match self.fields.get(k) { + Some(Value::Number(n)) => n.as_f64().unwrap_or(d), + Some(Value::String(s)) => drawcraft_doc::Unit::Points.parse(s).unwrap_or(d), + _ => d, + } + } + pub fn str(&self, k: &str) -> String { + match self.fields.get(k) { + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => String::new(), + } + } + pub fn bool(&self, k: &str) -> bool { + self.fields.get(k).and_then(Value::as_bool).unwrap_or(false) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UiState { + pub brightness: Brightness, + pub dock_tab: DockTab, + /// Icon panel currently popped out of the collapsed column. + pub open_panel: Option, + pub control_bar: bool, + pub toolbar: bool, + pub toolbar_double: bool, + pub status_bar: bool, + pub dock: bool, + pub view: ViewFlags, + pub dialog: Option, + /// Toolbar group whose flyout is open. + pub flyout: Option, + /// Last tool shown for each toolbar group (flyout selection sticks). + pub group_tool: Vec, + pub status: String, + pub palette_open: bool, + pub palette_query: String, + /// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. + pub screen_mode: u8, + /// Draw Normal / Behind / Inside. + pub draw_mode: u8, + pub about: bool, +} + +impl Default for UiState { + fn default() -> Self { + Self { + brightness: Brightness::Dark, + dock_tab: DockTab::Properties, + open_panel: None, + control_bar: true, + toolbar: true, + toolbar_double: false, + status_bar: true, + dock: true, + view: ViewFlags::default(), + dialog: None, + flyout: None, + group_tool: drawcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), + status: String::new(), + palette_open: false, + palette_query: String::new(), + screen_mode: 0, + draw_mode: 0, + about: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zoom_steps() { + assert_eq!(next_zoom(1.0, true), 1.5); + assert_eq!(next_zoom(1.0, false), 0.6667); + assert_eq!(next_zoom(640.0, true), 640.0); + assert_eq!(zoom_label(0.6667), "66.67%"); + assert_eq!(zoom_label(1.0), "100%"); + assert_eq!(zoom_label(0.125), "12.5%"); + } + + #[test] + fn dialog_fields() { + let d = Dialog::new("rectangle", serde_json::json!({"width": "1in", "height": 50})); + assert_eq!(d.f64("width", 0.0), 72.0); + assert_eq!(d.f64("height", 0.0), 50.0); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 00000000..9a42e47d --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,272 @@ +//! Design tokens (Illustrator's four UI brightness levels), fonts and egui style. + +use std::sync::Arc; + +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Brightness { + #[default] + Dark, + MediumDark, + MediumLight, + Light, +} + +impl Brightness { + pub const ALL: [Brightness; 4] = [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light]; + pub fn label(self) -> &'static str { + match self { + Brightness::Dark => "Dark", + Brightness::MediumDark => "Medium Dark", + Brightness::MediumLight => "Medium Light", + Brightness::Light => "Light", + } + } + pub fn id(self) -> &'static str { + match self { + Brightness::Dark => "dark", + Brightness::MediumDark => "mediumDark", + Brightness::MediumLight => "mediumLight", + Brightness::Light => "light", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s) || b.label().eq_ignore_ascii_case(s)) + } +} + +/// All colours and metrics the UI uses. Never hard-code a colour in a widget. +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub dark: bool, + pub app_bar: Color32, + pub panel: Color32, + pub panel_darker: Color32, + pub input: Color32, + pub input_border: Color32, + pub border: Color32, + pub divider: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_disabled: Color32, + pub icon: Color32, + pub hover: Color32, + pub tool_active: Color32, + pub accent: Color32, + pub accent_strong: Color32, + pub row_selected: Color32, + pub pasteboard: Color32, + pub ruler: Color32, + pub ruler_tick: Color32, + pub smart_guide: Color32, + pub guide: Color32, + pub measure_bg: Color32, + pub button: Color32, + pub radius: u8, +} + +impl Tokens { + pub fn for_brightness(b: Brightness) -> Self { + let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8); + let base = Tokens { + dark: true, + app_bar: hex(0x262626), + panel: hex(0x323232), + panel_darker: hex(0x282828), + input: hex(0x1f1f1f), + input_border: hex(0x464646), + border: hex(0x1b1b1b), + divider: hex(0x3f3f3f), + text: hex(0xd8d8d8), + text_dim: hex(0xa8a8a8), + text_disabled: hex(0x6e6e6e), + icon: hex(0xcfcfcf), + hover: hex(0x444444), + tool_active: hex(0x1d1d1d), + accent: hex(0x378ef0), + accent_strong: hex(0x1473e6), + row_selected: hex(0x484b50), + pasteboard: hex(0x1e1e1e), + ruler: hex(0x2f2f2f), + ruler_tick: hex(0x8a8a8a), + smart_guide: hex(0xff3dfc), + guide: hex(0x4affff), + measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235), + button: hex(0x444444), + radius: 3, + }; + match b { + Brightness::Dark => base, + Brightness::MediumDark => Tokens { + app_bar: hex(0x444444), + panel: hex(0x535353), + panel_darker: hex(0x474747), + input: hex(0x3a3a3a), + input_border: hex(0x6a6a6a), + border: hex(0x383838), + divider: hex(0x656565), + text: hex(0xeeeeee), + text_dim: hex(0xc4c4c4), + icon: hex(0xe6e6e6), + hover: hex(0x646464), + tool_active: hex(0x383838), + row_selected: hex(0x6a6d72), + pasteboard: hex(0x494949), + ruler: hex(0x4c4c4c), + ruler_tick: hex(0xb0b0b0), + button: hex(0x656565), + ..base + }, + Brightness::MediumLight => Tokens { + dark: false, + app_bar: hex(0xa9a9a9), + panel: hex(0xb8b8b8), + panel_darker: hex(0xaaaaaa), + input: hex(0xd6d6d6), + input_border: hex(0x8c8c8c), + border: hex(0x969696), + divider: hex(0x9e9e9e), + text: hex(0x1f1f1f), + text_dim: hex(0x3c3c3c), + text_disabled: hex(0x7c7c7c), + icon: hex(0x2a2a2a), + hover: hex(0xc8c8c8), + tool_active: hex(0x969696), + row_selected: hex(0x9fb4d6), + pasteboard: hex(0xa0a0a0), + ruler: hex(0xc2c2c2), + ruler_tick: hex(0x4a4a4a), + button: hex(0xcacaca), + ..base + }, + Brightness::Light => Tokens { + dark: false, + app_bar: hex(0xe4e4e4), + panel: hex(0xf0f0f0), + panel_darker: hex(0xe2e2e2), + input: hex(0xffffff), + input_border: hex(0xb4b4b4), + border: hex(0xcfcfcf), + divider: hex(0xd6d6d6), + text: hex(0x1f1f1f), + text_dim: hex(0x4b4b4b), + text_disabled: hex(0x9a9a9a), + icon: hex(0x303030), + hover: hex(0xdddddd), + tool_active: hex(0xcdcdcd), + row_selected: hex(0xc8daf7), + pasteboard: hex(0xdcdcdc), + ruler: hex(0xeaeaea), + ruler_tick: hex(0x5a5a5a), + button: hex(0xe0e0e0), + ..base + }, + } + } + + pub fn get(ctx: &egui::Context) -> Tokens { + ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::Dark)) + } + pub fn cr(&self) -> CornerRadius { + CornerRadius::same(self.radius) + } +} + +pub const FONT_UI: &str = "ui"; +pub const FONT_UI_SEMIBOLD: &str = "ui-semibold"; +pub const FONT_MONO: &str = "mono"; + +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(FONT_UI_SEMIBOLD.into())) +} +pub fn mono(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(FONT_MONO.into())) +} + +pub fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let add = |f: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + f.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + }; + add(&mut fonts, "SourceSans3", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")); + add(&mut fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf")); + add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); + let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let mut prop = vec!["SourceSans3".to_string()]; + prop.extend(fallback.clone()); + fonts.families.insert(FontFamily::Proportional, prop); + let mut semi = vec!["SourceSans3-Semibold".to_string()]; + semi.extend(fallback.clone()); + fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), semi); + let mut ui = vec!["SourceSans3".to_string()]; + ui.extend(fallback.clone()); + fonts.families.insert(FontFamily::Name(FONT_UI.into()), ui); + let mut mono = vec!["JetBrainsMono".to_string()]; + mono.extend(fallback); + fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono); + ctx.set_fonts(fonts); +} + +/// Apply tokens to egui's global style. +pub fn apply(ctx: &egui::Context, b: Brightness) { + let t = Tokens::for_brightness(b); + ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); + let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.panel; + v.window_fill = t.panel; + v.extreme_bg_color = t.input; + v.faint_bg_color = t.panel_darker; + v.window_stroke = Stroke::new(1.0, t.border); + v.window_corner_radius = CornerRadius::same(6); + v.menu_corner_radius = CornerRadius::same(6); + v.selection.bg_fill = t.accent.gamma_multiply(0.55); + v.selection.stroke = Stroke::new(1.0, t.accent); + v.hyperlink_color = t.accent; + v.override_text_color = Some(t.text); + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) }; + let w = &mut v.widgets; + for (wv, fill) in [(&mut w.noninteractive, t.panel), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.tool_active), (&mut w.open, t.hover)] { + wv.bg_fill = fill; + wv.weak_bg_fill = fill; + wv.corner_radius = t.cr(); + wv.fg_stroke = Stroke::new(1.0, t.text); + } + w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); + w.inactive.bg_stroke = Stroke::NONE; + w.hovered.bg_stroke = Stroke::new(1.0, t.input_border); + w.active.bg_stroke = Stroke::new(1.0, t.accent); + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(6.0, 5.0); + s.spacing.button_padding = egui::vec2(6.0, 2.0); + s.spacing.interact_size = egui::vec2(24.0, 22.0); + s.spacing.menu_margin = egui::Margin::same(6); + s.spacing.slider_width = 120.0; + s.text_styles = [ + (TextStyle::Small, FontId::proportional(10.5)), + (TextStyle::Body, FontId::proportional(12.5)), + (TextStyle::Button, FontId::proportional(12.5)), + (TextStyle::Heading, FontId::new(15.0, FontFamily::Name(FONT_UI_SEMIBOLD.into()))), + (TextStyle::Monospace, FontId::new(11.5, FontFamily::Name(FONT_MONO.into()))), + ] + .into(); + s.animation_time = 0.08; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brightness_roundtrip() { + for b in Brightness::ALL { + assert_eq!(Brightness::parse(b.id()), Some(b)); + assert_eq!(Brightness::parse(b.label()), Some(b)); + } + assert!(!Tokens::for_brightness(Brightness::Light).dark); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs new file mode 100644 index 00000000..9253c22a --- /dev/null +++ b/crates/ui-egui/src/toolbar.rs @@ -0,0 +1,182 @@ +//! The Tools panel: tool groups with flyouts, fill/stroke proxy, colour/gradient/none, drawing +//! modes, screen mode and the Edit Toolbar drawer. + +use drawcraft_tools::{TOOL_GROUPS, ToolInfo}; +use egui::{CornerRadius, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::{DrawcraftApp, icons, menus, widgets}; + +const CELL: f32 = 32.0; + +fn tool_tip(t: &ToolInfo) -> String { + match t.shortcut { + Some(s) => format!("{} ({})", t.label, s), + None => t.label.to_string(), + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let avail = ui.available_height(); + let need = TOOL_GROUPS.len() as f32 * (CELL - 1.0) + 190.0; + let cols = if app.ui.toolbar_double || avail < need { 2 } else { 1 }; + let w = CELL * cols as f32 + 10.0; + egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(5, 4)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + // Header: gripper with «/» toggle. + let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::click()); + let chev = if app.ui.toolbar_double { "chevrons-left" } else { "chevrons-right" }; + icons::paint(ui, chev, egui::Rect::from_center_size(hdr.center(), vec2(11.0, 11.0)), if hresp.hovered() { t.text } else { t.text_dim }); + if hresp.on_hover_text("Toggle single/double column").clicked() { + app.ui.toolbar_double = !app.ui.toolbar_double; + } + ui.add_space(2.0); + let active = app.session.tool_id(); + let mut open_flyout: Option<(usize, egui::Rect)> = None; + let groups: Vec<(usize, &'static [ToolInfo])> = TOOL_GROUPS.iter().copied().enumerate().collect(); + for row in groups.chunks(cols) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (gi, group) in row { + let shown_id = app.ui.group_tool.get(*gi).cloned().unwrap_or_else(|| group[0].id.to_string()); + let shown = group.iter().find(|x| x.id == shown_id).unwrap_or(&group[0]); + let is_active = group.iter().any(|x| x.id == active); + let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL - 2.0), Sense::click_and_drag()); + let bg = if is_active { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect.shrink(1.0), CornerRadius::same(4), bg); + let icon = icons::tool_icon(shown.icon); + icons::paint(ui, icon, rect.shrink(7.0), if is_active { t.text } else { t.icon }); + if group.len() > 1 { + let c = rect.right_bottom() + vec2(-4.0, -4.0); + ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-4.0, 0.0), c + vec2(0.0, -4.0)], t.text_dim, Stroke::NONE)); + } + let long_press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35)); + if resp.secondary_clicked() || long_press || (resp.clicked() && ui.input(|i| i.modifiers.alt) && group.len() > 1) { + if ui.input(|i| i.modifiers.alt) && resp.clicked() { + // Option-click cycles through the group. + let idx = group.iter().position(|x| x.id == shown.id).unwrap_or(0); + let next = group[(idx + 1) % group.len()].id; + app.select_tool(next); + } else if group.len() > 1 { + open_flyout = Some((*gi, rect)); + } + } else if resp.clicked() { + app.select_tool(shown.id); + } + resp.on_hover_text(tool_tip(shown)); + } + }); + ui.add_space(1.0); + } + if let Some(f) = open_flyout { + app.ui.flyout = Some(f.0); + ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-anchor"), f.1)); + } + ui.add_space(6.0); + // Fill / stroke proxy. + let (fill, stroke) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone()); + let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) { + Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()), + _ => (fill, stroke), + }; + ui.vertical_centered(|ui| { + let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 40.0); + if f { + app.session.fill_active = true; + } + if s { + app.session.fill_active = false; + } + if swap { + app.run("paint.swap", json!({})).ok(); + } + if def { + app.run("paint.default", json!({})).ok(); + } + }); + ui.add_space(4.0); + // Color / Gradient / None. + let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" }; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let s = 10.0; + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last)); + if resp.on_hover_text("Color (,)").clicked() { + app.run(target, json!({"color": last.to_hex()})).ok(); + } + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default())))); + if resp.on_hover_text("Gradient (.)").clicked() { + app.run(target, json!({"gradient": {"kind": "linear"}})).ok(); + } + let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click()); + widgets::paint_chip(ui, r, &drawcraft_color::Paint::None); + if resp.on_hover_text("None (/)").clicked() { + app.run(target, json!({"none": true})).ok(); + } + }); + ui.add_space(6.0); + ui.vertical_centered(|ui| { + let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"]; + let names = ["Draw Normal", "Draw Behind", "Draw Inside"]; + let m = app.ui.draw_mode as usize % 3; + if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() { + app.ui.draw_mode = (app.ui.draw_mode + 1) % 3; + } + if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() { + app.run("view.screenMode", json!({})).ok(); + } + if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() { + app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({}))); + } + }); + }); + flyout(app, ui.ctx()); +} + +fn flyout(app: &mut DrawcraftApp, ctx: &egui::Context) { + let Some(gi) = app.ui.flyout else { return }; + let Some(group) = TOOL_GROUPS.get(gi) else { return }; + let anchor: egui::Rect = ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); + let t = Tokens::get(ctx); + let mut chosen = None; + let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(4.0, 0.0)).show(ctx, |ui| { + egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_min_width(230.0); + for tool in group.iter() { + let active = app.session.tool_id() == tool.id; + let (r, resp) = ui.allocate_exact_size(vec2(230.0, 28.0), Sense::click()); + if resp.hovered() || active { + ui.painter().rect_filled(r, CornerRadius::same(4), if resp.hovered() { t.accent_strong } else { t.hover }); + } + if active { + ui.painter().rect_filled(egui::Rect::from_min_size(r.min + vec2(3.0, 11.0), vec2(4.0, 6.0)), 0.0, t.text); + } + let icon = icons::tool_icon(tool.icon); + icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(12.0, 5.0), vec2(18.0, 18.0)), t.text); + ui.painter().text(r.left_center() + vec2(38.0, 0.0), egui::Align2::LEFT_CENTER, tool.label, egui::FontId::proportional(12.5), t.text); + if let Some(sc) = tool.shortcut { + ui.painter().text(r.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, sc, egui::FontId::proportional(12.0), t.text_dim); + } + if resp.clicked() { + chosen = Some(tool.id); + } + } + }); + }); + if let Some(id) = chosen { + app.select_tool(id); + } else if resp.response.clicked_elsewhere() { + app.ui.flyout = None; + } + let _ = menus::pretty_shortcut(""); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 00000000..dc5eb6e5 --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,290 @@ +//! Shared widgets in Illustrator's panel style. Colours come from theme tokens. + +use drawcraft_color::Paint; +use drawcraft_doc::Unit; +use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons; +use crate::theme::{self, Tokens}; + +/// Square icon button; `selected` draws the pressed well. +pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + let bg = if selected { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon }); + if !tip.is_empty() { + resp.on_hover_text(tip) + } else { + resp + } +} + +/// Small flat text button (Quick Actions style). +pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); + let bg = if resp.is_pointer_button_down_on() { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + t.button + }; + ui.painter().rect_filled(rect, CornerRadius::same(4), bg); + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.0), t.text); + resp +} + +/// Blue call-to-action pill (dialog OK/Create). +pub fn primary_button(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), theme::semibold(12.5), Color32::WHITE); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); + let bg = if resp.hovered() { t.accent } else { t.accent_strong }; + ui.painter().rect_filled(rect, CornerRadius::same(14), bg); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE); + resp +} + +/// Outlined secondary pill (dialog Cancel). +pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), theme::semibold(12.5), t.text); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(14), t.hover); + } + ui.painter().rect_stroke(rect, CornerRadius::same(14), Stroke::new(1.5, t.text_dim), StrokeKind::Inside); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp +} + +/// Bold section header (Properties panel). +pub fn section_header(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(2.0); + ui.label(egui::RichText::new(text).font(theme::semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +/// Full-width 1 px divider with vertical margins. +pub fn divider(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.divider); + ui.add_space(6.0); +} + +pub fn dim_label(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5)) +} + +/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) +/// when the user commits (Enter / focus loss). Supports unit suffixes and arithmetic. +pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with(id); + let shown = value.map(|v| unit.format(v)).unwrap_or_default(); + let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); + let editing = ui.memory(|m| m.has_focus(id)); + if !editing { + buf = shown.clone(); + } + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(3)) + .inner_margin(egui::Margin::symmetric(5, 2)) + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text))) + .inner; + let commit = resp.lost_focus() && buf != shown; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if commit { unit.parse(&buf) } else { None } +} + +/// A plain number field (percent, degrees, counts) with optional suffix. +pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with(id); + let shown = { + let s = format!("{:.*}", decimals, value); + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; + format!("{s}{suffix}") + }; + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(3)) + .inner_margin(egui::Margin::symmetric(5, 2)) + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text))) + .inner; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if resp.lost_focus() && buf != shown { + buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::().ok() + } else { + None + } +} + +/// Draw a paint preview (swatch chip) into `rect`. +pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { + let p = ui.painter(); + match paint { + Paint::None => { + p.rect_filled(rect, 0.0, Color32::WHITE); + p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20))); + } + Paint::Solid { color, .. } => { + let [r, g, b, _] = color.to_rgba8(1.0); + p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); + } + Paint::Gradient(gp) => { + let n = 24; + let w = rect.width() / n as f32; + for i in 0..n { + let tt = (i as f32 + 0.5) / n as f32; + let (c, _) = gp.gradient.sample(tt); + let [r, g, b, _] = c.to_rgba8(1.0); + let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); + if gp.gradient.kind == drawcraft_color::GradientKind::Radial { + let (c, _) = gp.gradient.sample(1.0 - tt); + let [r, g, b, _] = c.to_rgba8(1.0); + let s = rect.width().min(rect.height()) * tt / 2.0; + p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); + } else { + p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); + } + } + } + Paint::Pattern { .. } => { + p.rect_filled(rect, 0.0, Color32::from_gray(200)); + for i in 0..4 { + let x = rect.left() + rect.width() * i as f32 / 4.0; + p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90))); + } + } + } +} + +/// The Fill/Stroke proxy pair (Illustrator's overlapping squares). Returns (fill clicked, stroke clicked, swap, default). +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active: bool, size: f32) -> (bool, bool, bool, bool) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + let s = size * 0.62; + let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); + let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); + let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click()); + let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click()); + let draw_fill = |ui: &Ui| { + paint_chip(ui, fill_r, fill); + ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + let draw_stroke = |ui: &Ui| { + let w = s * 0.28; + paint_chip(ui, stroke_r, stroke); + let inner = stroke_r.shrink(w); + ui.painter().rect_filled(inner, 0.0, t.panel); + ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); + ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + if fill_active { + draw_stroke(ui); + draw_fill(ui); + } else { + draw_fill(ui); + draw_stroke(ui); + } + // Swap arrow (top-right) and default (bottom-left) mini buttons. + let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); + let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); + let swap = ui.interact(swap_r, ui.id().with("swap-proxy"), Sense::click()); + let def = ui.interact(def_r, ui.id().with("default-proxy"), Sense::click()); + icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); + let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); + let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); + ui.painter().rect_filled(b, 0.0, Color32::BLACK); + ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_filled(a, 0.0, Color32::WHITE); + ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + ( + fill_resp.on_hover_text("Fill (X)").clicked(), + stroke_resp.on_hover_text("Stroke (X)").clicked(), + swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), + def.on_hover_text("Default Fill and Stroke (D)").clicked(), + ) +} + +/// A compact dropdown. Returns the chosen index. +pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let mut chosen = None; + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(CornerRadius::same(3)) + .show(ui, |ui| { + egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| { + for (i, o) in options.iter().enumerate() { + if ui.selectable_label(*o == current, *o).clicked() { + chosen = Some(i); + } + } + }) + }); + let _ = resp; + chosen +} + +/// The 3×3 reference point locator. Returns a new index when clicked. +pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + let size = 22.0; + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let mut out = None; + let step = size / 2.0 - 2.5; + ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle); + for i in 0..9 { + let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5); + let r = Rect::from_center_size(c, Vec2::splat(5.0)); + let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click()); + if resp.clicked() { + out = Some(i); + } + if i == current { + ui.painter().rect_filled(r, 0.0, t.text); + } else { + ui.painter().rect_filled(r, 0.0, t.panel); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + } + } + out +} + +/// A toggle icon (e.g. eye / lock columns) — returns clicked. +pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + if on { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon }); + } else if resp.hovered() { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); + } + resp.on_hover_text(tip).clicked() +}