diff --git a/Cargo.lock b/Cargo.lock index e3eb0c53..843aa08a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,237 @@ # 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.3", + "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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arbitrary" version = "1.5.0" @@ -17,6 +242,26 @@ dependencies = [ "derive_arbitrary", ] +[[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.5", + "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" @@ -32,6 +277,195 @@ 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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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" @@ -50,6 +484,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[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" @@ -65,6 +520,37 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[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" @@ -88,7 +574,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -97,18 +583,195 @@ 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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "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.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "cpufeatures" version = "0.2.17" @@ -133,6 +796,12 @@ 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 = "crypto-common" version = "0.1.7" @@ -143,12 +812,49 @@ dependencies = [ "typenum", ] +[[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 = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -157,7 +863,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -170,6 +876,24 @@ dependencies = [ "crypto-common", ] +[[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.5", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -178,15 +902,277 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[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 = "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.5", + "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.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[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 = "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 = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[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", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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" @@ -196,6 +1182,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" @@ -205,6 +1223,31 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "figcraft" +version = "0.1.0" +dependencies = [ + "arboard", + "eframe", + "egui", + "env_logger", + "figcraft-engine", + "figcraft-text", + "figcraft-ui-egui", + "image", + "log", + "rfd", + "serde_json", + "webbrowser", + "winresource", +] + [[package]] name = "figcraft-color" version = "0.1.0" @@ -222,7 +1265,7 @@ dependencies = [ "figcraft-geom", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -242,7 +1285,7 @@ dependencies = [ "log", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -320,6 +1363,31 @@ dependencies = [ "skrifa 0.47.0", ] +[[package]] +name = "figcraft-ui-egui" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "egui", + "figcraft-color", + "figcraft-doc", + "figcraft-engine", + "figcraft-geom", + "figcraft-layout", + "figcraft-render", + "figcraft-text", + "image", + "log", + "serde", + "serde_json", +] + +[[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" @@ -337,6 +1405,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.12.6" @@ -368,6 +1442,97 @@ 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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[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 = "generic-array" version = "0.14.7" @@ -378,6 +1543,39 @@ dependencies = [ "version_check", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gif" version = "0.14.2" @@ -388,13 +1586,153 @@ 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", +] + +[[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.5", + "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]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +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", @@ -406,18 +1744,148 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +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 = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] name = "image" @@ -433,6 +1901,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -460,7 +1929,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", ] [[package]] @@ -469,6 +1953,139 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.13.1" @@ -488,6 +2105,40 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linesweeper" version = "0.4.0" @@ -497,10 +2148,43 @@ dependencies = [ "arrayvec", "kurbo", "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 = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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" @@ -522,6 +2206,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" @@ -552,6 +2245,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", + "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" @@ -559,6 +2326,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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +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.3", + "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.5", + "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.3", + "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.3", + "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.3", + "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.5", +] + +[[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.5", + "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.3", + "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.3", + "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.3", +] + +[[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.5", + "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.5", + "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.3", + "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.3", + "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.5", + "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.3", + "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.5", + "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.3", + "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.3", + "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.5", + "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.3", + "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.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -567,25 +2668,226 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "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.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "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" @@ -595,6 +2897,45 @@ 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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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" @@ -604,6 +2945,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "pxfm" version = "0.1.30" @@ -616,6 +2963,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" @@ -625,6 +2981,42 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -658,6 +3050,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" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[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 = "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.5", + "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 = "roxmltree" version = "0.20.0" @@ -673,12 +3154,92 @@ 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 2.13.2", + "errno", + "libc", + "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 = "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" @@ -706,7 +3267,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -722,6 +3283,26 @@ 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 = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -733,12 +3314,44 @@ dependencies = [ "digest", ] +[[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" @@ -774,6 +3387,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" @@ -792,6 +3411,99 @@ 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.5", + "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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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" @@ -811,6 +3523,17 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -822,13 +3545,66 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "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]] name = "thiserror" 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]] @@ -839,7 +3615,21 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "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", + "weezl", + "zune-jpeg", ] [[package]] @@ -868,24 +3658,152 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[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 = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[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 = "unicode-bidi" 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" @@ -898,12 +3816,36 @@ 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 = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.48.1" @@ -932,18 +3874,926 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +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_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +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 = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[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", + "bit-vec", + "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", + "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.5", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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.3", + "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 = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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" @@ -954,9 +4804,217 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" name = "xtask" version = "0.1.0" dependencies = [ + "figcraft-color", + "figcraft-doc", + "figcraft-engine", + "figcraft-render", + "image", "serde_json", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[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.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "2.4.2" @@ -970,7 +5028,7 @@ dependencies = [ "flate2", "indexmap", "memchr", - "thiserror", + "thiserror 2.0.21", "zopfli", ] @@ -1012,3 +5070,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/Cargo.toml b/Cargo.toml index 22e48fc5..3176df52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "xtask"] +members = ["crates/*", "apps/*", "xtask"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml new file mode 100644 index 00000000..ff120a4a --- /dev/null +++ b/apps/figcraft/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "figcraft" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "FigCraft desktop app" +default-run = "figcraft" + +[lints] +workspace = true + +[dependencies] +figcraft-engine = { workspace = true } +figcraft-ui-egui = { workspace = true } +figcraft-text = { workspace = true } +egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +serde_json = { workspace = true } +log = { workspace = true } +rfd = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } +webbrowser = { workspace = true } +image = { workspace = true } +env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } + +[build-dependencies] +winresource = "0.1" diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs new file mode 100644 index 00000000..8c277b69 --- /dev/null +++ b/apps/figcraft/build.rs @@ -0,0 +1,21 @@ +//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere). +fn main() { + println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "windows" { + let mut res = winresource::WindowsResource::new(); + let ico = "../../assets/app-icon/figcraft.ico"; + if std::path::Path::new(ico).exists() { + res.set_icon(ico); + } + res.set("ProductName", "FigCraft"); + res.set("FileDescription", "FigCraft"); + res.set("CompanyName", "ArtCraft"); + if let Err(e) = res.compile() { + println!("cargo::warning=winresource: {e}"); + } + } + let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default(); + println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}"); + println!("cargo::rerun-if-env-changed=FIGCRAFT_BUILD_SHA"); +} diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs new file mode 100644 index 00000000..fbc38026 --- /dev/null +++ b/apps/figcraft/src/control_server.rs @@ -0,0 +1,69 @@ +//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI +//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use figcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +type Queue = Arc>>; +type CtxSlot = Arc>>; + +pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + log::error!("control server: can't bind 127.0.0.1:{port}: {e} (another app may own it; pick another port)"); + eprintln!("FigCraft control server failed to bind port {port}: {e}"); + return; + } + }; + eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); + let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { + for stream in listener.incoming().flatten() { + let (q, c) = (queue.clone(), ctx.clone()); + let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c)); + } + }); + if let Err(e) = spawned { + log::error!("control server thread: {e}"); + } +} + +fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) { + let Ok(read_half) = stream.try_clone() else { return }; + let mut out = stream; + let reader = BufReader::new(read_half); + for line in reader.lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(v) => { + let id = v.get("id").cloned().unwrap_or(Value::Null); + let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = v.get("params").cloned().unwrap_or(json!({})); + let (tx, rx) = std::sync::mpsc::channel(); + queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx }); + if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { + c.request_repaint(); + } + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(r) => match r.result { + Ok(res) => json!({"id": r.id, "ok": true, "result": res}), + Err(e) => json!({"id": r.id, "ok": false, "error": e}), + }, + Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}), + } + } + Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs new file mode 100644 index 00000000..4693aa27 --- /dev/null +++ b/apps/figcraft/src/main.rs @@ -0,0 +1,111 @@ +//! FigCraft desktop app. +//! +//! Usage: `figcraft [--control ] [files…]` +//! +//! `--control ` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}` +//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] + +mod control_server; +mod services; + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use figcraft_ui_egui::{ControlRequest, FigApp}; +use serde_json::json; + +struct App { + fig: FigApp, + queue: Arc>>, + ctx_slot: Arc>>, +} + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + { + let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.is_none() { + *slot = Some(ctx.clone()); + } + } + let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + self.fig.control_queue.extend(q.drain(..)); + drop(q); + // Window title follows the document. + let title = match self.fig.session.ds() { + Ok(ds) => format!("{}{} – FigCraft", ds.doc.name, if ds.is_dirty() { " •" } else { "" }), + Err(_) => "FigCraft".into(), + }; + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // Last-resort guard: a panic in the UI frame becomes a message, not a lost document. + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.fig.ui(ui))); + if let Err(e) = res { + let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + log::error!("UI panic: {msg}"); + self.fig.toast(format!("Internal error (please report): {msg}")); + services::emergency_save(&self.fig.session); + } + } + + fn on_exit(&mut self) { + services::save_state(&self.fig); + } +} + +fn main() -> eframe::Result { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("FigCraft {}{}", env!("CARGO_PKG_VERSION"), option_env!("FIGCRAFT_BUILD_SHA").filter(|s| !s.is_empty()).map(|s| format!(" ({s})")).unwrap_or_default()); + return Ok(()); + } + let mut port: Option = std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut i = 0; + while i < args.len() { + match args.get(i).map(String::as_str) { + Some("--control") => { + port = args.get(i + 1).and_then(|p| p.parse().ok()); + i += 1; + } + Some(f) if !f.starts_with("--") => files.push(f.to_string()), + _ => {} + } + i += 1; + } + // System fonts: index them in the background. + std::thread::spawn(figcraft_text::scan_system_fonts); + let mut session = Session::empty(); + for f in &files { + if let Err(e) = session.execute("file.open", &json!({"path": f})) { + log::error!("{f}: {e}"); + } + } + let svc = services::Desktop::load(); + if let Some(prefs) = svc.prefs.clone() { + session.prefs = prefs; + } + if session.docs.is_empty() { + let _ = session.execute("file.new", &json!({})); + } + let mut fig = FigApp::new(session, Box::new(svc)); + fig.ui.home = false; + let queue: Arc>> = Arc::new(Mutex::new(Vec::new())); + let ctx_slot: Arc>> = Arc::new(Mutex::new(None)); + if let Some(p) = port { + control_server::start(p, queue.clone(), ctx_slot.clone()); + } + let icon = services::window_icon(); + let mut viewport = egui::ViewportBuilder::default().with_title("FigCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true); + if let Some(icon) = icon { + viewport = viewport.with_icon(icon); + } + let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; + eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { fig, queue, ctx_slot })))) +} diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs new file mode 100644 index 00000000..52e0495e --- /dev/null +++ b/apps/figcraft/src/services.rs @@ -0,0 +1,102 @@ +//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences and recents. + +use std::path::PathBuf; + +use figcraft_engine::{Prefs, Session}; +use figcraft_ui_egui::{FigApp, Services}; +use serde_json::{Value, json}; + +pub struct Desktop { + clipboard: Option, + recents: Vec, + pub prefs: Option, +} + +fn config_dir() -> Option { + #[cfg(target_os = "macos")] + let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")); + #[cfg(target_os = "windows")] + let base = std::env::var_os("APPDATA").map(PathBuf::from); + #[cfg(all(unix, not(target_os = "macos")))] + let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); + base.map(|b| b.join("FigCraft")) +} + +impl Desktop { + pub fn load() -> Self { + let state: Value = config_dir().and_then(|d| std::fs::read_to_string(d.join("state.json")).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})); + let recents = state.get("recents").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()); + Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs } + } +} + +pub fn save_state(app: &FigApp) { + let Some(dir) = config_dir() else { return }; + let _ = std::fs::create_dir_all(&dir); + let v = json!({"recents": app.services.recent_files(), "prefs": app.session.prefs}); + let tmp = dir.join("state.json.tmp"); + if std::fs::write(&tmp, v.to_string()).is_ok() { + let _ = std::fs::rename(&tmp, dir.join("state.json")); + } +} + +/// After an unexpected panic: write every dirty document to the recovery folder. +pub fn emergency_save(s: &Session) { + let Some(dir) = config_dir().map(|d| d.join("Recovery")) else { return }; + let _ = std::fs::create_dir_all(&dir); + for d in &s.docs { + if d.is_dirty() + && let Ok(bytes) = figcraft_format_save(&d.doc) + { + let name = d.doc.name.replace(['/', '\\', ':'], "_"); + let _ = std::fs::write(dir.join(format!("{name}-{}.figcraft", d.uid)), bytes); + } + } +} + +fn figcraft_format_save(doc: &figcraft_engine::doc::Document) -> Result, String> { + let v = serde_json::to_vec(doc).map_err(|e| e.to_string())?; + Ok(v) +} + +impl Services for Desktop { + fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option { + rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file() + } + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option { + rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file() + } + fn clipboard_set_text(&mut self, s: &str) { + if let Some(c) = self.clipboard.as_mut() { + let _ = c.set_text(s.to_string()); + } + } + fn clipboard_get_text(&mut self) -> Option { + self.clipboard.as_mut()?.get_text().ok() + } + fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { + if let Some(c) = self.clipboard.as_mut() { + let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) }); + } + } + fn open_url(&mut self, url: &str) { + let _ = webbrowser::open(url); + } + fn recent_files(&self) -> Vec { + self.recents.clone() + } + fn add_recent(&mut self, path: &str) { + self.recents.retain(|p| p != path); + self.recents.insert(0, path.to_string()); + self.recents.truncate(20); + } +} + +/// The window icon from the bundled app icon (if present). +pub fn window_icon() -> Option { + let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png"); + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) +} diff --git a/assets/app-icon/figcraft-1024.png b/assets/app-icon/figcraft-1024.png new file mode 100644 index 00000000..22a722f3 Binary files /dev/null and b/assets/app-icon/figcraft-1024.png differ diff --git a/assets/app-icon/figcraft-128.png b/assets/app-icon/figcraft-128.png new file mode 100644 index 00000000..8d735b8d Binary files /dev/null and b/assets/app-icon/figcraft-128.png differ diff --git a/assets/app-icon/figcraft-16.png b/assets/app-icon/figcraft-16.png new file mode 100644 index 00000000..2b56b65d Binary files /dev/null and b/assets/app-icon/figcraft-16.png differ diff --git a/assets/app-icon/figcraft-256.png b/assets/app-icon/figcraft-256.png new file mode 100644 index 00000000..02200c6b Binary files /dev/null and b/assets/app-icon/figcraft-256.png differ diff --git a/assets/app-icon/figcraft-32.png b/assets/app-icon/figcraft-32.png new file mode 100644 index 00000000..6aa430ea Binary files /dev/null and b/assets/app-icon/figcraft-32.png differ diff --git a/assets/app-icon/figcraft-48.png b/assets/app-icon/figcraft-48.png new file mode 100644 index 00000000..3f573349 Binary files /dev/null and b/assets/app-icon/figcraft-48.png differ diff --git a/assets/app-icon/figcraft-512.png b/assets/app-icon/figcraft-512.png new file mode 100644 index 00000000..5b81f3f8 Binary files /dev/null and b/assets/app-icon/figcraft-512.png differ diff --git a/assets/app-icon/figcraft-64.png b/assets/app-icon/figcraft-64.png new file mode 100644 index 00000000..291ccad4 Binary files /dev/null and b/assets/app-icon/figcraft-64.png differ diff --git a/assets/app-icon/figcraft.ico b/assets/app-icon/figcraft.ico new file mode 100644 index 00000000..923c4bf5 Binary files /dev/null and b/assets/app-icon/figcraft.ico differ diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..8d735b8d Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..2b56b65d Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..02200c6b Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..6aa430ea Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..3f573349 Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..5b81f3f8 Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.figcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png new file mode 100644 index 00000000..291ccad4 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png differ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 00000000..43483833 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "figcraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "FigCraft egui frontend (the Figma-style UI: canvas, toolbar, layers, design and prototype panels, menus); swappable: nothing below depends on it" + +[lints] +workspace = true + +[dependencies] +figcraft-geom = { workspace = true } +figcraft-color = { workspace = true } +figcraft-doc = { workspace = true } +figcraft-text = { workspace = true } +figcraft-layout = { workspace = true } +figcraft-render = { workspace = true } +figcraft-engine = { workspace = true } +egui = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +base64 = { workspace = true } +image = { workspace = true } + +[build-dependencies] +serde_json = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..6f3fe9ea --- /dev/null +++ b/crates/ui-egui/build.rs @@ -0,0 +1,33 @@ +//! Compiles contributor credits into the binary (craftrules standards/contributors.md): +//! usernames from `contributors/contributors.json` (generated by craftrules' contributors.py). +//! A missing or malformed file still builds, with no contributors listed. +use std::fmt::Write as _; +use std::path::PathBuf; + +fn main() { + let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("../../contributors"); + let json = root.join("contributors.json"); + println!("cargo::rerun-if-changed={}", json.display()); + println!("cargo::rerun-if-changed={}", root.join("people.toml").display()); + let mut out = String::from("pub static CONTRIBUTORS: &[&str] = &["); + match std::fs::read_to_string(&json) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(v) => { + if let Some(list) = v.get("contributors").and_then(|c| c.as_array()) { + for c in list { + if let Some(login) = c.get("login").and_then(|l| l.as_str()) { + let _ = write!(out, "{login:?}, "); + } + } + } + } + Err(e) => println!("cargo::warning=contributors.json: {e}"), + }, + Err(_) => println!("cargo::warning=no contributors/contributors.json (run craftrules scripts/contributors.py)"), + } + out.push_str("];\n"); + let dest = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); + if let Err(e) = std::fs::write(&dest, out) { + println!("cargo::error=writing {}: {e}", dest.display()); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 00000000..1886df78 --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,661 @@ +//! The canvas: the rendered page plus selection, hover, guides, labels and text-editing overlays. + +use std::sync::Arc; + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::{Document, NodeId, NodeKind}; +use figcraft_engine::tools::{Cursor, PointerEvent, PointerKind, ToolId, selection_box}; +use figcraft_geom::Point; +use serde_json::json; + +use crate::FigApp; +use crate::render_worker::{Job, Worker}; +use crate::theme::{ACCENT, COMPONENT, Tokens, c32, regular, semibold}; + +pub struct CanvasState { + pub rect: Rect, + texture: Option, + tex_view: Option, + submitted: Option, + generation: u64, + dragging: bool, + space_pan: bool, + middle_pan: Option<(Pos2, f64, f64)>, + pub last_pointer: Option, +} + +impl Default for CanvasState { + fn default() -> Self { + Self { rect: Rect::NOTHING, texture: None, tex_view: None, submitted: None, generation: 0, dragging: false, space_pan: false, middle_pan: None, last_pointer: None } + } +} + +#[derive(Clone, PartialEq)] +struct JobKey { + doc: usize, + page: NodeId, + origin: (i64, i64), + scale: i64, + size: (u32, u32), + outline: bool, + skip: Vec, +} + +impl FigApp { + /// Page → screen (logical points). + pub fn to_screen(&self, x: f64, y: f64) -> Pos2 { + let Ok(ds) = self.session.ds() else { return Pos2::ZERO }; + let (sx, sy) = ds.view.page_to_screen(x, y); + pos2(self.canvas.rect.min.x + sx as f32, self.canvas.rect.min.y + sy as f32) + } + pub fn to_page(&self, p: Pos2) -> Point { + let Ok(ds) = self.session.ds() else { return Point::ZERO }; + let (x, y) = ds.view.screen_to_page(f64::from(p.x - self.canvas.rect.min.x), f64::from(p.y - self.canvas.rect.min.y)); + Point::new(x, y) + } +} + +fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) -> PointerEvent { + let p = app.to_page(pos); + let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); + PointerEvent { kind, x: p.x, y: p.y, sx: f64::from(pos.x), sy: f64::from(pos.y), shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, zoom } +} + +pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { + let rect = ui.max_rect(); + app.canvas.rect = rect; + let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); + if let Ok(ds) = app.session.ds_mut() { + if (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5 { + // Keep the centre; the first sizing after opening re-fits. + let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0; + ds.view.vw = f64::from(rect.width()); + ds.view.vh = f64::from(rect.height()); + if first { + ds.zoom_to_fit(); + } + } + } + input(app, ui, &resp); + request_render(app, worker, ppp); + if let Some(done) = worker.poll() + && done.generation == app.canvas.generation + { + let img = egui::ColorImage::from_rgba_premultiplied([done.view.width as usize, done.view.height as usize], &done.rgba); + match &mut app.canvas.texture { + Some(t) => t.set(img, egui::TextureOptions::LINEAR), + None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", img, egui::TextureOptions::LINEAR)), + } + app.canvas.tex_view = Some(done.view); + app.ui.render_ms = done.ms; + } + let painter = ui.painter_at(rect); + let t = Tokens::get(&ctx); + // Page background (also behind a stale/partial texture). + let bg = app + .session + .ds() + .ok() + .and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) { + Some(NodeKind::Page { background, .. }) => Some(c32(*background)), + _ => None, + }) + .unwrap_or(t.canvas); + painter.rect_filled(rect, CornerRadius::ZERO, bg); + if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) { + // Re-project the last render onto the current view. + let tl = app.to_screen(v.origin.0, v.origin.1); + let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); + let w = (f64::from(v.width) / v.scale * zoom) as f32; + let h = (f64::from(v.height) / v.scale * zoom) as f32; + painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + overlays(app, &painter, &t); + // Cursor. + if resp.hovered() { + let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() { + if ctx.input(|i| i.pointer.any_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + } else { + match app.session.tool.overlay.cursor { + Cursor::Default => egui::CursorIcon::Default, + Cursor::Crosshair | Cursor::Pen => egui::CursorIcon::Crosshair, + Cursor::Move => egui::CursorIcon::Move, + Cursor::Resize(a) => { + let a = ((a % 180.0) + 180.0) % 180.0; + if !(22.5..157.5).contains(&a) { + egui::CursorIcon::ResizeHorizontal + } else if a < 67.5 { + egui::CursorIcon::ResizeNwSe + } else if a < 112.5 { + egui::CursorIcon::ResizeVertical + } else { + egui::CursorIcon::ResizeNeSw + } + } + Cursor::Rotate => egui::CursorIcon::Alias, + Cursor::Grab => egui::CursorIcon::Grab, + Cursor::Grabbing => egui::CursorIcon::Grabbing, + Cursor::Text => egui::CursorIcon::Text, + Cursor::Copy => egui::CursorIcon::Copy, + } + }; + ctx.set_cursor_icon(c); + } +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { + let Ok(ds) = app.session.ds() else { return }; + let rect = app.canvas.rect; + let scale = ds.view.zoom * f64::from(ppp); + let (ox, oy) = ds.view.origin(); + let w = (rect.width() * ppp).ceil().max(1.0) as u32; + let h = (rect.height() * ppp).ceil().max(1.0) as u32; + // Skip the text being edited? No: Figma draws it live; the caret is an overlay. + let key = JobKey { + doc: Arc::as_ptr(&ds.doc) as usize, + page: ds.page, + origin: ((ox * 1000.0) as i64, (oy * 1000.0) as i64), + scale: (scale * 10000.0) as i64, + size: (w, h), + outline: ds.view.outlines, + skip: vec![], + }; + if app.canvas.submitted.as_ref() == Some(&key) { + return; + } + app.canvas.generation += 1; + let job = Job { doc: ds.doc.clone(), page: ds.page, view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) }, outline: ds.view.outlines, skip: vec![], generation: app.canvas.generation }; + worker.submit(job); + app.canvas.submitted = Some(key); +} + +fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { + let ctx = ui.ctx().clone(); + let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| (i.modifiers, i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))); + let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); + let typing_elsewhere = ctx.memory(|m| m.focused().is_some()); + app.canvas.space_pan = space && !editing_text && !typing_elsewhere; + // Dropped files import onto the canvas. + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + for f in dropped { + let path = f.path().to_path_buf(); + let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); + if path.exists() { + app.run("file.import", json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})); + } else if let Ok(bytes) = f.bytes() { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); + app.run("file.import", json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})); + } + } + let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { + return; + }; + // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. + if resp.hovered() { + if (zoom_delta - 1.0).abs() > 1e-4 { + let anchor = app.to_page(pos); + if let Ok(ds) = app.session.ds_mut() { + let z = ds.view.zoom * f64::from(zoom_delta); + ds.view.set_zoom_at(z, (anchor.x, anchor.y)); + ds.revision += 1; + } + } else if scroll != egui::Vec2::ZERO + && let Ok(ds) = app.session.ds_mut() + { + let z = ds.view.zoom.max(1e-6); + let (dx, dy) = if mods.shift && scroll.x == 0.0 { (scroll.y, 0.0) } else { (scroll.x, scroll.y) }; + ds.view.cx -= f64::from(dx) / z; + ds.view.cy -= f64::from(dy) / z; + ds.revision += 1; + } + } + // Middle-button / space panning. + let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle)); + if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() { + if let Ok(ds) = app.session.ds_mut() { + match app.canvas.middle_pan { + None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)), + Some((p0, cx, cy)) => { + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - f64::from(pos.x - p0.x) / z; + ds.view.cy = cy - f64::from(pos.y - p0.y) / z; + ds.revision += 1; + } + } + } + if !middle_down && !ctx.input(|i| i.pointer.primary_down()) { + app.canvas.middle_pan = None; + } + return; + } + if resp.secondary_clicked() { + // Right-click: select what's under the pointer, then the context menu. + let ev = pointer_event(app, PointerKind::Hover, pos, &mods); + if let Ok(ds) = app.session.ds() { + let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); + if let Some(leaf) = doc.hit_test(page, Point::new(ev.x, ev.y), 2.0 / ev.zoom) { + let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, mods.command); + if !sel.contains(&target) { + app.session.set_selection(vec![target]); + } + } + } + app.ui.context_menu = Some((pos, app.session.selection())); + return; + } + if resp.double_clicked() { + let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.canvas.dragging = false; + return; + } + let primary_pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + let primary_down = ctx.input(|i| i.pointer.primary_down()); + let primary_released = ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + let kind = if primary_pressed && resp.hovered() { + app.canvas.dragging = true; + app.ui.context_menu = None; + app.ui.tool_flyout = None; + // Clicking the canvas takes focus from panel fields. + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + Some(PointerKind::Down) + } else if app.canvas.dragging && primary_released { + app.canvas.dragging = false; + Some(PointerKind::Up) + } else if app.canvas.dragging && primary_down { + Some(PointerKind::Move) + } else if resp.hovered() { + Some(PointerKind::Hover) + } else { + None + }; + if let Some(k) = kind { + let ev = pointer_event(app, k, pos, &mods); + app.canvas.last_pointer = Some(Point::new(ev.x, ev.y)); + if let Err(e) = app.session.pointer(&ev) { + app.toast(e.to_string()); + } + app.handle_ui_requests(); + } +} + +fn stroke(c: Color32, w: f32) -> Stroke { + Stroke::new(w, c) +} + +/// A node's oriented outline in screen space. +fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos2; 4]> { + let n = doc.node(id)?; + let w = doc.world_transform(id); + let corners = [Point::new(0.0, 0.0), Point::new(n.width, 0.0), Point::new(n.width, n.height), Point::new(0.0, n.height)]; + Some(corners.map(|c| { + let p = w * c; + app.to_screen(p.x, p.y) + })) +} + +fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { + painter.add(Shape::closed_line(q.to_vec(), s)); +} + +/// The outline path of shapes (ellipses, vectors) for hover/selection outlines. +fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option>> { + let n = doc.node(id)?; + if !matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Vector { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::BooleanOp { .. }) { + return None; + } + let (path, _) = figcraft_render::node_outline(doc, n)?; + let w = doc.world_transform(id); + let mut out = Vec::new(); + let mut cur: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(path.iter(), 0.5 / app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0), |el| { + use figcraft_geom::kurbo::PathEl::*; + match el { + MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + let q = w * p; + cur.push(app.to_screen(q.x, q.y)); + } + LineTo(p) => { + let q = w * p; + cur.push(app.to_screen(q.x, q.y)); + } + ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + } + _ => {} + } + }); + if cur.len() > 1 { + out.push(cur); + } + Some(out) +} + +fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let page = ds.page; + let sel = ds.selection.clone(); + let zoom = ds.view.zoom; + let view = ds.view.clone(); + // Pixel grid at high zoom. + if view.pixel_grid && zoom >= 8.0 { + let r = app.canvas.rect; + let p0 = app.to_page(r.min); + let p1 = app.to_page(r.max); + let col = Color32::from_black_alpha(18); + let mut x = p0.x.floor(); + while x <= p1.x { + let s = app.to_screen(x, 0.0).x; + painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0)); + x += 1.0; + } + let mut y = p0.y.floor(); + while y <= p1.y { + let s = app.to_screen(0.0, y).y; + painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0)); + y += 1.0; + } + } + let Some(pg) = doc.node(page) else { return }; + // Layout grids and frame labels for top-level frames. + for c in &pg.children { + let Some(n) = doc.node(*c) else { continue }; + if !n.visible { + continue; + } + if view.layout_grids && !n.grids.is_empty() { + let w = doc.world_transform(*c); + for g in n.grids.iter().filter(|g| g.visible) { + let col = c32(g.color); + for r in figcraft_layout::grid_lines(g, n.width, n.height) { + let a = w * Point::new(r.x0, r.y0); + let b = w * Point::new(r.x1, r.y1); + let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)); + if g.kind == figcraft_doc::GridKind::Grid { + painter.line_segment([sa, sb], stroke(col, 1.0)); + } else { + painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col); + } + } + } + } + let labelled = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); + if labelled { + let Some(b) = doc.world_bounds(*c) else { continue }; + let tl = app.to_screen(b.x0, b.y0); + let selected = sel.contains(c); + let hovered = ds.hover == Some(*c); + let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); + let col = if is_comp { + COMPONENT + } else if selected || hovered { + ACCENT + } else { + t.text_secondary + }; + if matches!(n.kind, NodeKind::Section { .. }) { + // Section titles are pills inside the top-left. + let galley = painter.layout_no_wrap(n.name.clone(), semibold(11.0), t.text); + let r = Rect::from_min_size(tl + vec2(0.0, 0.0), galley.size() + vec2(16.0, 8.0)); + painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover }); + painter.galley(r.min + vec2(8.0, 4.0), galley, if selected { Color32::WHITE } else { t.text }); + } else { + let max_w = (b.width() * zoom) as f32; + let mut x = tl.x; + if is_comp { + crate::icons::paint(painter, Rect::from_min_size(pos2(x, tl.y - 16.0), vec2(12.0, 12.0)), crate::icons::Icon::Component, col); + x += 14.0; + } + let galley = painter.layout(n.name.clone(), regular(11.0), col, max_w.max(20.0)); + if galley.rows.len() <= 1 { + painter.galley(pos2(x, tl.y - 16.0), galley, col); + } + } + } + } + // Prototype connections. + if app.ui.right_tab == crate::RightTab::Prototype { + for id in doc.subtree(page) { + let Some(n) = doc.node(id) else { continue }; + for it in &n.interactions { + for a in &it.actions { + let Some(dest) = a.dest() else { continue }; + let (Some(from), Some(to)) = (doc.world_bounds(id), doc.world_bounds(dest)) else { continue }; + let a0 = app.to_screen(from.x1, from.center().y); + let b0 = app.to_screen(to.x0, to.center().y); + let mid = (b0.x - a0.x).abs().max(60.0) * 0.5; + let curve = egui::epaint::CubicBezierShape::from_points_stroke( + [a0, a0 + vec2(mid, 0.0), b0 - vec2(mid, 0.0), b0], + false, + Color32::TRANSPARENT, + stroke(ACCENT, 1.5), + ); + painter.add(curve); + painter.circle_filled(a0, 4.0, ACCENT); + painter.add(Shape::convex_polygon(vec![b0, b0 + vec2(-7.0, -4.0), b0 + vec2(-7.0, 4.0)], ACCENT, Stroke::NONE)); + } + } + } + } + // Hover outline. + if let Some(h) = ds.hover + && !sel.contains(&h) + && app.session.tool.tool == ToolId::Move + && app.session.prefs.highlight_on_hover + { + let col = if matches!(doc.node(h).map(|n| &n.kind), Some(NodeKind::Component { .. } | NodeKind::Instance { .. } | NodeKind::ComponentSet { .. })) { COMPONENT } else { ACCENT }; + match outline_shape(app, &doc, h) { + Some(lines) => { + for l in lines { + painter.add(Shape::line(l, stroke(col, 1.5))); + } + } + None => { + if let Some(q) = node_quad(app, &doc, h) { + quad(painter, q, stroke(col, 1.5)); + } + } + } + } + // Drop target / insertion indicator. + let ov = &app.session.tool.overlay; + if let Some(d) = ov.drop_target + && let Some(q) = node_quad(app, &doc, d) + { + quad(painter, q, stroke(ACCENT, 1.0)); + } + if let Some([x0, y0, x1, y1]) = ov.insert_line { + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); + } + // Selection. + let editing = ds.text_edit.clone(); + if !sel.is_empty() && editing.is_none() { + let any_component = sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Component { .. } | NodeKind::Instance { .. } | NodeKind::ComponentSet { .. }))); + let col = if any_component { COMPONENT } else { ACCENT }; + for id in &sel { + match outline_shape(app, &doc, *id) { + Some(lines) => { + for l in lines { + painter.add(Shape::line(l, stroke(col, 1.0))); + } + } + None => { + if let Some(q) = node_quad(app, &doc, *id) { + quad(painter, q, stroke(col, 1.0)); + } + } + } + } + if let Some(b) = selection_box(&doc, &sel) { + let q = b.map(|p| app.to_screen(p.x, p.y)); + if sel.len() > 1 { + quad(painter, q, stroke(col, 1.0)); + } + let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); + for (i, c) in q.iter().enumerate() { + if is_line && i >= 2 { + continue; + } + let r = Rect::from_center_size(*c, vec2(8.0, 8.0)); + painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); + painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); + } + // Size label under the box. + if app.session.prefs.show_dimensions + && let Some(bb) = doc.bounds_of(&sel) + { + let (w, h) = if sel.len() == 1 { sel.first().and_then(|i| doc.node(*i)).map(|n| (n.width, n.height)).unwrap_or((bb.width(), bb.height())) } else { (bb.width(), bb.height()) }; + let label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; + let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); + let cx = q.iter().map(|p| p.x).sum::() / 4.0; + let galley = painter.layout_no_wrap(label, semibold(11.0), Color32::WHITE); + let r = Rect::from_center_size(pos2(cx, bottom + 8.0 + galley.size().y / 2.0 + 3.0), galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, CornerRadius::same(3), col); + painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + } + // Auto layout: show padding/gap hints on a single selected AL frame. + if sel.len() == 1 + && let Some(id) = sel.first() + && let Some(n) = doc.node(*id) + && let Some(al) = &n.layout + && zoom > 0.25 + { + let w = doc.world_transform(*id); + let [pt, pr, pb, pl] = al.padding; + let pink = Color32::from_rgba_unmultiplied(0xF2, 0x48, 0x8C, 30); + for (x0, y0, x1, y1) in [(0.0, 0.0, n.width, pt), (n.width - pr, 0.0, n.width, n.height), (0.0, n.height - pb, n.width, n.height), (0.0, 0.0, pl, n.height)] { + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + continue; + } + let a = w * Point::new(x0, y0); + let b = w * Point::new(x1, y1); + painter.rect_filled(Rect::from_two_pos(app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)), CornerRadius::ZERO, pink); + } + } + } + // Text editing: caret and selection. + if let Some(te) = editing + && let Some(n) = doc.node(te.id) + && let Some((layout, dy)) = figcraft_text::layout_node(n) + { + let w = doc.world_transform(te.id); + let to = |x: f64, y: f64| { + let p = w * Point::new(x, y + dy); + app.to_screen(p.x, p.y) + }; + if let Some(q) = node_quad(app, &doc, te.id) { + quad(painter, q, stroke(ACCENT, 1.0)); + } + let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); + if a != b { + for r in layout.selection_rects(a, b) { + let qq = [to(r.x0, r.y0), to(r.x1, r.y0), to(r.x1, r.y1), to(r.x0, r.y1)]; + painter.add(Shape::convex_polygon(qq.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + } + } + let (cx, li) = layout.caret(te.caret); + if let Some(line) = layout.lines.get(li).or(layout.lines.last()) { + let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0; + if blink || a != b { + painter.line_segment([to(cx, line.top), to(cx, line.top + line.height)], stroke(t.text, 1.0)); + } + painter.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } else { + let p = to(0.0, 0.0); + painter.line_segment([p, p + vec2(0.0, 14.0 * zoom as f32)], stroke(t.text, 1.0)); + } + } + // Smart guides and measurements. + for g in &ov.guides { + let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); + painter.line_segment([a, b], stroke(t.guide, 1.0)); + if let Some(l) = &g.label { + let galley = painter.layout_no_wrap(l.clone(), semibold(10.0), Color32::WHITE); + let mid = pos2((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); + let r = Rect::from_center_size(mid, galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, CornerRadius::same(3), t.guide); + painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + } + // Marquee. + if let Some([x0, y0, x1, y1]) = ov.marquee { + let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); + painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); + painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); + } + // Pen path. + if !ov.pen.is_empty() { + let pts: Vec = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect(); + if app.session.tool.tool == ToolId::Pencil { + painter.add(Shape::line(pts, stroke(t.text, 2.0))); + } else { + for p in &pts { + painter.circle_filled(*p, 3.5, Color32::WHITE); + painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); + } + if let Some([x0, y0, x1, y1]) = ov.rubber { + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0)); + } + } + } + // Rulers. + if view.rulers { + rulers(app, painter, t, &doc, &sel); + } +} + +fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel: &[NodeId]) { + let r = app.canvas.rect; + let size = 20.0; + painter.rect_filled(Rect::from_min_size(r.min, vec2(r.width(), size)), CornerRadius::ZERO, t.panel); + painter.rect_filled(Rect::from_min_size(r.min, vec2(size, r.height())), CornerRadius::ZERO, t.panel); + let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); + let step = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0].into_iter().find(|s| s * zoom >= 50.0).unwrap_or(10000.0); + let p0 = app.to_page(r.min); + let p1 = app.to_page(r.max); + // Highlight the selection's span. + if let Some(b) = doc.bounds_of(sel) { + let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + } + let mut x = (p0.x / step).floor() * step; + while x <= p1.x { + let s = app.to_screen(x, 0.0).x; + painter.line_segment([pos2(s, r.min.y + size - 6.0), pos2(s, r.min.y + size)], stroke(t.text_tertiary, 1.0)); + painter.text(pos2(s + 2.0, r.min.y + 3.0), Align2::LEFT_TOP, fmt(x), regular(9.0), t.text_secondary); + x += step; + } + let mut y = (p0.y / step).floor() * step; + while y <= p1.y { + let s = app.to_screen(0.0, y).y; + painter.line_segment([pos2(r.min.x + size - 6.0, s), pos2(r.min.x + size, s)], stroke(t.text_tertiary, 1.0)); + painter.text(pos2(r.min.x + 3.0, s + 2.0), Align2::LEFT_TOP, fmt(y), regular(9.0), t.text_secondary); + y += step; + } + painter.line_segment([pos2(r.min.x, r.min.y + size), pos2(r.max.x, r.min.y + size)], stroke(t.border, 1.0)); + painter.line_segment([pos2(r.min.x + size, r.min.y), pos2(r.min.x + size, r.max.y)], stroke(t.border, 1.0)); +} + +pub fn fmt(v: f64) -> String { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs new file mode 100644 index 00000000..a5c61316 --- /dev/null +++ b/crates/ui-egui/src/colorpicker.rs @@ -0,0 +1,503 @@ +//! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders, +//! hex and RGB fields, gradient stops, image fills and document colours. + +use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Hsb, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::{EffectKind, NodeId, NodeKind}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, c32, regular, semibold}; +use crate::widgets; + +#[derive(Clone, Debug, PartialEq)] +pub enum PickerTarget { + PageBackground, + Paint { fills: bool, index: usize, ids: Vec }, + Effect { ids: Vec, index: usize }, +} + +fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { + let doc = app.session.doc().ok()?; + match target { + PickerTarget::PageBackground => match &doc.node(app.session.page().ok()?)?.kind { + NodeKind::Page { background, .. } => Some(Paint::solid(*background)), + _ => None, + }, + PickerTarget::Paint { fills, index, ids } => { + let n = doc.node(*ids.first()?)?; + let list = if *fills { &n.fills } else { &n.strokes }; + list.get(*index).cloned() + } + PickerTarget::Effect { ids, index } => match &doc.node(*ids.first()?)?.effects.get(*index)?.kind { + EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => Some(Paint::solid(s.color)), + _ => None, + }, + } +} + +/// Write `paint` to the target. `live` edits are part of an ongoing drag. +fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { + if live { + let _ = app.session.begin_interaction("Color"); + } + let r = match target { + PickerTarget::PageBackground => { + let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE); + app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()})) + } + PickerTarget::Paint { fills, index, ids } => app.session.execute(if *fills { "fill.set" } else { "stroke.set" }, &json!({"ids": ids, "index": index, "paint": paint})), + PickerTarget::Effect { ids, index } => { + let Some(e) = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned()) else { return }; + let c = paint.solid_color().unwrap_or(Color::BLACK); + let mut e2 = e; + if let EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) = &mut e2.kind { + s.color = c; + } + app.session.execute("effect.set", &json!({"ids": ids, "index": index, "effect": e2})) + } + }; + if let Err(e) = r { + app.toast(e.to_string()); + } + if let Some(c) = paint.solid_color() { + app.session.recent_colors.retain(|x| *x != c); + app.session.recent_colors.insert(0, c); + app.session.recent_colors.truncate(16); + } +} + +fn finish(app: &mut FigApp) { + if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) { + let _ = app.session.commit_interaction(); + } +} + +fn hue_color(h: f32) -> Color32 { + c32(Hsb { h, s: 1.0, b: 1.0 }.to_color()) +} + +fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { + // White → hue horizontally, then transparent → black vertically. + let mut m = Mesh::default(); + let hc = hue_color(hsb.h); + let corners = [(rect.left_top(), Color32::WHITE), (rect.right_top(), hc), (rect.right_bottom(), hc), (rect.left_bottom(), Color32::WHITE)]; + for (p, c) in corners { + m.colored_vertex(p, c); + } + m.add_triangle(0, 1, 2); + m.add_triangle(0, 2, 3); + ui.painter().add(m); + let mut m2 = Mesh::default(); + for (p, c) in [(rect.left_top(), Color32::TRANSPARENT), (rect.right_top(), Color32::TRANSPARENT), (rect.right_bottom(), Color32::BLACK), (rect.left_bottom(), Color32::BLACK)] { + m2.colored_vertex(p, c); + } + m2.add_triangle(0, 1, 2); + m2.add_triangle(0, 2, 3); + ui.painter().add(m2); + let resp = ui.interact(rect, Id::new("cp_sv"), Sense::click_and_drag()); + let mut changed = false; + if (resp.dragged() || resp.clicked() || resp.drag_started()) + && let Some(p) = resp.interact_pointer_pos() + { + hsb.s = ((p.x - rect.min.x) / rect.width()).clamp(0.0, 1.0); + hsb.b = 1.0 - ((p.y - rect.min.y) / rect.height()).clamp(0.0, 1.0); + changed = true; + } + let knob = pos2(rect.min.x + hsb.s * rect.width(), rect.min.y + (1.0 - hsb.b) * rect.height()); + ui.painter().circle_stroke(knob, 6.0, Stroke::new(2.0, Color32::WHITE)); + ui.painter().circle_stroke(knob, 7.0, Stroke::new(1.0, Color32::from_black_alpha(80))); + (changed, resp.drag_stopped() || resp.clicked()) +} + +fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { + let n = 12; + let mut m = Mesh::default(); + for i in 0..=n { + let t = i as f32 / n as f32; + let x = rect.min.x + t * rect.width(); + let c = hue_color(t * 360.0); + m.colored_vertex(pos2(x, rect.min.y), c); + m.colored_vertex(pos2(x, rect.max.y), c); + if i > 0 { + let k = (i * 2) as u32; + m.add_triangle(k - 2, k - 1, k); + m.add_triangle(k - 1, k, k + 1); + } + } + ui.painter().add(m); + slider_knob(ui, rect, hsb.h / 360.0, Id::new("cp_hue")).map(|(t, done)| { + hsb.h = (t * 360.0).clamp(0.0, 359.9); + (true, done) + }).unwrap_or((false, false)) +} + +fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { + let resp = ui.interact(rect.expand(4.0), id, Sense::click_and_drag()); + let x = rect.min.x + t.clamp(0.0, 1.0) * rect.width(); + ui.painter().circle_stroke(pos2(x, rect.center().y), rect.height() / 2.0 + 1.0, Stroke::new(2.0, Color32::WHITE)); + ui.painter().circle_stroke(pos2(x, rect.center().y), rect.height() / 2.0 + 2.0, Stroke::new(1.0, Color32::from_black_alpha(80))); + if (resp.dragged() || resp.clicked() || resp.drag_started()) + && let Some(p) = resp.interact_pointer_pos() + { + return Some((((p.x - rect.min.x) / rect.width()).clamp(0.0, 1.0), resp.drag_stopped() || resp.clicked())); + } + if resp.drag_stopped() { + return Some((t, true)); + } + None +} + +fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { + widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); + let mut m = Mesh::default(); + let opaque = c32(color.with_alpha(1.0)); + for (p, c) in [(rect.left_top(), Color32::TRANSPARENT), (rect.right_top(), opaque), (rect.right_bottom(), opaque), (rect.left_bottom(), Color32::TRANSPARENT)] { + m.colored_vertex(p, c); + } + m.add_triangle(0, 1, 2); + m.add_triangle(0, 2, 3); + ui.painter().add(m); + slider_knob(ui, rect, color.a, Id::new("cp_alpha")) +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + let Some(target) = app.ui.picker.clone() else { return }; + let Some(mut paint) = current_paint(app, &target) else { + app.ui.picker = None; + return; + }; + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let pos = pos2(screen.max.x - app.ui.right_width - 260.0, 140.0); + let mut open = true; + let mut close_now = false; + egui::Window::new("color_picker") + .title_bar(false) + .fixed_pos(pos) + .fixed_size(vec2(240.0, 0.0)) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0))) + .open(&mut open) + .show(ctx, |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // Header. + let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Custom", semibold(11.0), t.text); + let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); + let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); + icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); + if cresp.clicked() { + close_now = true; + } + // Paint type. + if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground) { + let kinds = [(Icon::Fill, "Solid"), (Icon::LayoutH, "Linear"), (Icon::Ellipse, "Radial"), (Icon::Rotate, "Angular"), (Icon::Instance, "Diamond"), (Icon::Image, "Image")]; + let active = match &paint.kind { + PaintKind::Solid { .. } => 0, + PaintKind::Gradient(g) => match g.kind { + GradientKind::Linear => 1, + GradientKind::Radial => 2, + GradientKind::Angular => 3, + GradientKind::Diamond => 4, + }, + PaintKind::Image(_) => 5, + }; + ui.horizontal(|ui| { + ui.add_space(12.0); + if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 34.0) { + let base = paint.preview_color(); + paint.kind = match i { + 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, + 5 => { + // Pick an image file. + match app.services.pick_open("Choose image", &["png", "jpg", "jpeg", "webp", "gif"]) { + Some(p) => match std::fs::read(&p) { + Ok(bytes) => { + let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)); + match hash { + Some(h) => Paint::image(h, ImageScaleMode::Fill).kind, + None => paint.kind.clone(), + } + } + Err(_) => paint.kind.clone(), + }, + None => paint.kind.clone(), + } + } + k => { + let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1]; + match &paint.kind { + PaintKind::Gradient(g) => PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) }), + _ => PaintKind::Gradient(Gradient::default_for(kind, base)), + } + } + }; + write(app, &target, &paint, false); + } + }); + } + // Gradient stops. + let mut edit_color: Option = match &paint.kind { + PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)), + _ => None, + }; + let sel_stop_id = Id::new("cp_stop"); + if let PaintKind::Gradient(g) = &paint.kind { + let mut g = g.clone(); + let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); + let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); + let bar = bar.shrink2(vec2(16.0, 6.0)); + widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0); + let n = 24; + for i in 0..n { + let a = i as f32 / n as f32; + let b = (i + 1) as f32 / n as f32; + let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y)); + ui.painter().rect_filled(r, CornerRadius::ZERO, c32(g.sample((a + b) / 2.0))); + } + let mut changed = false; + let mut done = false; + for (i, s) in g.stops.clone().iter().enumerate() { + let x = bar.min.x + s.position * bar.width(); + let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); + let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); + ui.painter().rect_filled(r, CornerRadius::same(3), c32(s.color.with_alpha(1.0))); + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }), StrokeKind::Outside); + if resp.clicked() || resp.drag_started() { + sel = i; + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + && let Some(st) = g.stops.get_mut(i) + { + st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); + changed = true; + } + if resp.drag_stopped() { + done = true; + } + } + // Click on the bar adds a stop. + let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); + if bresp.clicked() + && let Some(p) = bresp.interact_pointer_pos() + { + let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0); + g.stops.push(ColorStop { position: pos, color: g.sample(pos) }); + sel = g.stops.len() - 1; + changed = true; + done = true; + } + ui.data_mut(|d| d.insert_temp(sel_stop_id, sel)); + if changed { + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, !done); + } + if done { + finish(app); + } + edit_color = g.stops.get(sel).map(|s| s.color); + ui.horizontal(|ui| { + ui.add_space(16.0); + if ui.small_button("Remove stop").clicked() && g.stops.len() > 2 { + g.stops.remove(sel); + ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize)); + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, false); + } + if ui.small_button("Flip").clicked() { + for s in &mut g.stops { + s.position = 1.0 - s.position; + } + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, false); + } + if ui.small_button("Rotate 90°").clicked() { + let (fx, fy) = g.from; + let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx)); + g.to = rot(g.to); + g.width = rot(g.width); + paint.kind = PaintKind::Gradient(g.clone()); + write(app, &target, &paint, false); + } + }); + } + if let PaintKind::Image(img) = &paint.kind { + let mut img = img.clone(); + ui.horizontal(|ui| { + ui.add_space(16.0); + let resp = widgets::dropdown(ui, &format!("{:?}", img.scale_mode), 100.0, None); + egui::Popup::menu(&resp).show(|ui| { + for (l, m) in [("Fill", ImageScaleMode::Fill), ("Fit", ImageScaleMode::Fit), ("Crop", ImageScaleMode::Crop), ("Tile", ImageScaleMode::Tile)] { + if crate::right::menu_item(ui, l, "") { + img.scale_mode = m; + paint.kind = PaintKind::Image(img.clone()); + write(app, &target, &paint, false); + ui.close(); + } + } + }); + if icons::button(ui, Icon::Rotate, 24.0, false, "Rotate 90°").clicked() { + img.rotation = (img.rotation + 1) % 4; + paint.kind = PaintKind::Image(img.clone()); + write(app, &target, &paint, false); + } + }); + for (label, val) in [("Exposure", img.filters.exposure), ("Contrast", img.filters.contrast), ("Saturation", img.filters.saturation), ("Temperature", img.filters.temperature), ("Tint", img.filters.tint), ("Highlights", img.filters.highlights), ("Shadows", img.filters.shadows)] { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new(label).font(regular(11.0))); + let mut v = val; + let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false)); + if r.changed() { + let f = &mut img.filters; + match label { + "Exposure" => f.exposure = v, + "Contrast" => f.contrast = v, + "Saturation" => f.saturation = v, + "Temperature" => f.temperature = v, + "Tint" => f.tint = v, + "Highlights" => f.highlights = v, + _ => f.shadows = v, + } + paint.kind = PaintKind::Image(img.clone()); + write(app, &target, &paint, !r.drag_stopped()); + } + if r.drag_stopped() { + finish(app); + } + }); + } + } + if let Some(color) = edit_color { + let mut hsb = ui.data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))).filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()).map(|(h, s, b, _)| Hsb { h, s, b }).unwrap_or_else(|| Hsb::from_color(color)); + let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); + let (c1, d1) = sv_square(ui, sq, &mut hsb); + let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); + let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0)); + if icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))), Icon::Eyedropper, 24.0, false, "Pick color (I)").clicked() { + app.ui.picker = None; + app.run("edit.pickColor", json!({})); + return; + } + let (c2, d2) = hue_slider(ui, hr, &mut hsb); + let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover()); + let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0)); + let mut new_alpha = None; + if let Some((a, d)) = alpha_slider(ui, ar, color) { + new_alpha = Some((a, d)); + } + let mut nc = hsb.to_color().with_alpha(color.a); + let mut done = d1 || d2; + let mut changed = c1 || c2; + if let Some((a, d)) = new_alpha { + nc = nc.with_alpha(a); + changed = true; + done |= d; + } + // Hex + alpha fields. + let mut hex_set = None; + let mut alpha_set = None; + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, "Hex", t.text_secondary); + if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &nc.to_hex(), 96.0, "") { + hex_set = Color::from_hex(&h); + } + if let Some(widgets::FieldEdit::Set(v)) = widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(nc.a) * 100.0).round()), "%", 56.0, 1.0) { + alpha_set = Some((v / 100.0).clamp(0.0, 1.0) as f32); + } + }); + if let Some(h) = hex_set { + nc = h.with_alpha(if h.a < 1.0 { h.a } else { nc.a }); + hsb = Hsb::from_color(nc); + changed = true; + done = true; + } + if let Some(a) = alpha_set { + nc = nc.with_alpha(a); + changed = true; + done = true; + } + // RGB readout. + let [r, g, b, _] = nc.to_rgba8(); + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, &format!("RGB {r} {g} {b}"), t.text_secondary); + }); + ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + if changed { + match &mut paint.kind { + PaintKind::Solid { color } => { + *color = nc.with_alpha(1.0); + paint.opacity = nc.a; + } + PaintKind::Gradient(g) => { + let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0); + if let Some(s) = g.stops.get_mut(sel) { + s.color = nc; + } + } + PaintKind::Image(_) => {} + } + write(app, &target, &paint, !done); + } + if done { + finish(app); + } + } + // Document / recent colours. + let recent = app.session.recent_colors.clone(); + if !recent.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, "Recent", t.text_secondary); + }); + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + for c in recent.iter().take(14) { + if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() { + if let PaintKind::Solid { color } = &mut paint.kind { + *color = c.with_alpha(1.0); + paint.opacity = c.a; + } + write(app, &target, &paint, false); + } + } + }); + } + ui.add_space(12.0); + }); + if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + finish(app); + app.ui.picker = None; + } + let _ = Pos2::ZERO; +} + +fn figcraft_format_add(doc: &mut figcraft_doc::Document, bytes: Vec) -> Option { + // Images are keyed by content hash; the engine's image.place uses the same scheme. + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + let hash = { + use std::hash::{Hash, Hasher}; + let mut s = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut s); + format!("img{:016x}{:08x}", s.finish(), bytes.len()) + }; + doc.images.insert(hash.clone(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(bytes), width: w, height: h }); + Some(hash) +} + +trait WithStops { + fn with_stops(self, stops: Vec) -> Self; +} + +impl WithStops for Gradient { + fn with_stops(mut self, stops: Vec) -> Self { + if stops.len() >= 2 { + self.stops = stops; + } + self + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 00000000..d2a55d50 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,268 @@ +//! The control channel's UI-thread side. The app crate accepts JSON lines on a localhost socket +//! and queues [`ControlRequest`]s; [`drain`] runs them at the start of a frame so replies reflect +//! the real UI. Methods: +//! +//! - `engine.execute {command, params}` — run any command (no dialogs open) +//! - `engine.commands {}` — every command with its params doc +//! - `document.inspect {depth?, allPages?}` / `node.get {id, depth?}` +//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings +//! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}` +//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas +//! - `ui.type {text}` — type into the text being edited +//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) +//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?}` +//! - `ui.screenshot {path}` — PNG of the window (needs a visible window) +//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window) +//! - `app.quit {}` + +use egui::Context; +use figcraft_engine::{PointerEvent, PointerKind}; +use serde_json::{Value, json}; + +use crate::FigApp; + +pub struct ControlReply { + pub id: Value, + pub result: Result, +} + +pub struct ControlRequest { + pub id: Value, + pub method: String, + pub params: Value, + pub reply: std::sync::mpsc::Sender, +} + +/// Pending screenshot replies. +pub struct PendingShot { + pub id: Value, + pub path: String, + pub reply: std::sync::mpsc::Sender, +} + +fn pending(ctx: &Context) -> std::sync::Arc>> { + ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone()) +} + +pub fn drain(app: &mut FigApp, ctx: &Context) { + // Finish screenshots that arrived. + let shots: Vec> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + .collect() + }); + if let Some(img) = shots.last() { + let list = pending(ctx); + let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for p in list.drain(..) { + let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]})); + let _ = p.reply.send(ControlReply { id: p.id, result: res }); + } + } + let reqs = std::mem::take(&mut app.control_queue); + for r in reqs { + if r.method == "ui.screenshot" { + let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string(); + pending(ctx).lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply }); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + ctx.request_repaint(); + continue; + } + let res = handle(app, ctx, &r.method, &r.params); + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + ctx.request_repaint(); + } +} + +fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> { + let [w, h] = img.size; + let mut rgba = Vec::with_capacity(w * h * 4); + for p in &img.pixels { + rgba.extend_from_slice(&p.to_srgba_unmultiplied()); + } + let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?; + buf.save(path).map_err(|e| e.to_string()) +} + +fn b(p: &Value, k: &str) -> bool { + p.get(k).and_then(Value::as_bool).unwrap_or(false) +} + +/// Handle one control method (also used by tests and the headless snapshot tool). +pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result { + match method { + "engine.execute" => { + let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; + let params = p.get("params").cloned().unwrap_or(json!({})); + let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + // Programmatic calls never open dialogs. + app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); + app.handle_ui_requests(); + r + } + "engine.commands" => Ok(Value::Array( + figcraft_engine::all_commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history})).collect(), + )), + "document.inspect" => { + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8) as usize; + Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) + } + "node.get" => { + let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?; + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize; + app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()) + } + "ui.inspect" => Ok(app.inspect()), + "ui.pointer" => pointer(app, p), + "ui.key" => { + let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?; + let mut spec = String::new(); + for (k, m) in [("shift", "Shift"), ("alt", "Alt"), ("cmd", "Cmd"), ("ctrl", "Ctrl")] { + if b(p, k) { + spec.push_str(m); + spec.push('+'); + } + } + spec.push_str(key); + // Text editing keys go to the editor. + if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) { + figcraft_engine::textedit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?; + return Ok(json!({"ok": true})); + } + match crate::shortcuts::parse(&spec) { + Some(_) => { + let id = figcraft_engine::catalog::MENU.iter().find(|m| !m.shortcut.is_empty() && m.shortcut.eq_ignore_ascii_case(&spec)).map(|m| m.id); + match id { + Some(id) => { + if id == "select.none" { + app.session.escape().map_err(|e| e.to_string())?; + } else { + crate::menus::invoke(app, id); + } + Ok(json!({"command": id})) + } + None => match key { + "Escape" => app.session.escape().map(|_| json!({"ok": true})).map_err(|e| e.to_string()), + "Delete" | "Backspace" => app.session.execute("edit.delete", &json!({})).map_err(|e| e.to_string()), + "Enter" => { + let _ = app.session.pen_finish(); + Ok(json!({"ok": true})) + } + "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => { + let (dx, dy) = match key { + "ArrowLeft" => (-1, 0), + "ArrowRight" => (1, 0), + "ArrowUp" => (0, -1), + _ => (0, 1), + }; + app.session.execute("transform.nudge", &json!({"dx": dx, "dy": dy, "big": b(p, "shift")})).map_err(|e| e.to_string()) + } + _ => Err(format!("no shortcut `{spec}`")), + }, + } + } + None => Err(format!("unknown key `{key}`")), + } + } + "ui.type" => { + let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?; + figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?; + Ok(json!({"ok": true})) + } + "ui.menu.invoke" => { + let id = p.get("id").and_then(Value::as_str).ok_or("`id` required")?; + crate::menus::invoke(app, id); + Ok(json!({"ok": true, "lastError": app.ui.last_error})) + } + "ui.set" => { + if let Some(t) = p.get("rightTab").and_then(Value::as_str) { + app.ui.right_tab = match t { + "prototype" => crate::RightTab::Prototype, + "inspect" => crate::RightTab::Inspect, + _ => crate::RightTab::Design, + }; + } + if let Some(t) = p.get("leftTab").and_then(Value::as_str) { + app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File }; + } + if let Some(t) = p.get("theme").and_then(Value::as_str) { + app.session.prefs.theme = t.to_string(); + } + if let Some(h) = p.get("home").and_then(Value::as_bool) { + app.ui.home = h; + } + if let Some(h) = p.get("palette").and_then(Value::as_bool) { + app.ui.palette = if h { Some(String::new()) } else { None }; + } + if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) { + app.ui.main_menu = h; + } + Ok(app.inspect()) + } + "ui.render" => { + let path = p.get("path").and_then(Value::as_str).ok_or("`path` required")?; + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + let ids: Vec = p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default(); + let mut params = json!({"format": "png", "scale": scale, "path": path}); + if !ids.is_empty() { + params["ids"] = json!(ids); + } + app.session.execute("file.export", ¶ms).map_err(|e| e.to_string()) + } + "app.quit" => { + app.ui.quit = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + Ok(json!({"ok": true})) + } + other => Err(format!("unknown method `{other}`")), + } +} + +fn pointer(app: &mut FigApp, p: &Value) -> Result { + let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); + let at = |x: Option, y: Option, sx: Option, sy: Option| -> Option<(f64, f64)> { + match (x, y, sx, sy) { + (Some(x), Some(y), _, _) => Some((x, y)), + (_, _, Some(sx), Some(sy)) => { + let pt = app.to_page(egui::pos2(sx as f32, sy as f32)); + Some((pt.x, pt.y)) + } + _ => None, + } + }; + let f = |k: &str| p.get(k).and_then(Value::as_f64); + let (x, y) = at(f("x"), f("y"), f("sx"), f("sy")).ok_or("pass x, y (page) or sx, sy (screen)")?; + let mk = |kind: PointerKind, x: f64, y: f64| PointerEvent { kind, x, y, sx: x * zoom, sy: y * zoom, shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl"), zoom }; + let kind = p.get("kind").and_then(Value::as_str).unwrap_or("click"); + let run = |app: &mut FigApp, ev: PointerEvent| app.session.pointer(&ev).map_err(|e| e.to_string()); + match kind { + "down" => run(app, mk(PointerKind::Down, x, y))?, + "move" => run(app, mk(PointerKind::Move, x, y))?, + "up" => run(app, mk(PointerKind::Up, x, y))?, + "hover" => run(app, mk(PointerKind::Hover, x, y))?, + "double" => run(app, mk(PointerKind::DoubleClick, x, y))?, + "click" => { + run(app, mk(PointerKind::Down, x, y))?; + run(app, mk(PointerKind::Up, x, y))?; + } + "drag" => { + let to = p.get("to").and_then(Value::as_array).ok_or("drag needs `to: [x, y]`")?; + let tx = to.first().and_then(Value::as_f64).ok_or("bad `to`")?; + let ty = to.get(1).and_then(Value::as_f64).ok_or("bad `to`")?; + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + run(app, mk(PointerKind::Down, x, y))?; + for i in 1..=steps { + let t = i as f64 / steps as f64; + run(app, mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t))?; + } + run(app, mk(PointerKind::Up, tx, ty))?; + } + other => return Err(format!("unknown pointer kind `{other}`")), + } + app.handle_ui_requests(); + Ok(json!({"selection": app.session.selection(), "tool": app.session.tool.tool.id()})) +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs new file mode 100644 index 00000000..288d5aad --- /dev/null +++ b/crates/ui-egui/src/credits.rs @@ -0,0 +1,3 @@ +//! Contributor credits compiled in by `build.rs`. + +include!(concat!(env!("OUT_DIR"), "/credits.rs")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 00000000..0c86f6e5 --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,253 @@ +//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, variables, export. + +use egui::{Align2, Color32, Context, CornerRadius, Id, RichText, Sense, Stroke, vec2}; +use figcraft_engine::catalog::MENU; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::toolbar::pretty_shortcut; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { + let t = Tokens::get(ctx); + egui::Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)).frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20))) +} + +pub fn show(app: &mut FigApp, ctx: &Context) { + let t = Tokens::get(ctx); + if app.ui.about { + let mut open = true; + window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT); + icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE); + ui.vertical(|ui| { + ui.label(RichText::new("FigCraft").font(semibold(18.0))); + ui.label(RichText::new(format!("Version {VERSION}")).color(t.text_secondary)); + }); + }); + ui.add_space(12.0); + ui.label("Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."); + ui.add_space(8.0); + ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft"); + ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft"); + ui.add_space(8.0); + ui.label(RichText::new("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.").color(t.text_secondary).font(regular(11.0))); + let credits = crate::credits::CONTRIBUTORS; + if !credits.is_empty() { + ui.add_space(8.0); + ui.label(RichText::new("Contributors").font(semibold(12.0))); + ui.label(credits.iter().map(|c| format!("@{c}")).collect::>().join(" · ")); + } + }); + if !open { + app.ui.about = false; + } + } + if app.ui.preferences { + let mut open = true; + window("Preferences", ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(360.0); + let prefs = app.session.prefs.clone(); + let mut toggles = vec![]; + for (label, val, cmd) in [ + ("Keep tool selected after use", prefs.keep_tool, "pref.keepTool"), + ("Highlight layers on hover", prefs.highlight_on_hover, "pref.highlightHover"), + ("Rename duplicated layers", prefs.rename_duplicates, "pref.renameDuplicates"), + ("Show dimensions on objects", prefs.show_dimensions, "pref.showDimensions"), + ("Use number keys for opacity", prefs.number_keys_opacity, "pref.numberKeysOpacity"), + ] { + if crate::widgets::checkbox(ui, val, label) { + toggles.push(cmd); + } + } + for c in toggles { + app.run(c, json!({})); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Nudge amount"); + let mut small = prefs.nudge_small; + let mut big = prefs.nudge_big; + let a = ui.add(egui::DragValue::new(&mut small).range(0.1..=100.0)); + let b = ui.add(egui::DragValue::new(&mut big).range(1.0..=1000.0)); + if a.changed() || b.changed() { + app.run("pref.nudge", json!({"small": small, "big": big})); + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Theme"); + for (l, c) in [("Light", "view.themeLight"), ("Dark", "view.themeDark"), ("System", "view.themeSystem")] { + if ui.selectable_label(app.session.prefs.theme.eq_ignore_ascii_case(l), l).clicked() { + app.run(c, json!({})); + } + } + }); + }); + if !open { + app.ui.preferences = false; + } + } + if app.ui.shortcuts_panel { + let mut open = true; + egui::Window::new("Keyboard shortcuts").open(&mut open).default_size(vec2(720.0, 420.0)).show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.columns(3, |cols| { + let items: Vec<_> = MENU.iter().filter(|m| !m.shortcut.is_empty()).collect(); + let per = items.len().div_ceil(3); + for (ci, col) in cols.iter_mut().enumerate() { + for m in items.iter().skip(ci * per).take(per) { + col.horizontal(|ui| { + ui.label(RichText::new(m.label).font(regular(11.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(pretty_shortcut(m.shortcut)).color(t.text_secondary).font(regular(11.0))); + }); + }); + } + } + }); + }); + }); + if !open { + app.ui.shortcuts_panel = false; + } + } + if let Some((mut find, mut replace)) = app.ui.find.clone() { + let mut open = true; + let mut action = None; + egui::Window::new("Find and replace").open(&mut open).anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)).resizable(false).show(ctx, |ui| { + ui.set_width(280.0); + ui.add(egui::TextEdit::singleline(&mut find).hint_text("Find").desired_width(260.0)); + ui.add(egui::TextEdit::singleline(&mut replace).hint_text("Replace with").desired_width(260.0)); + ui.horizontal(|ui| { + if ui.button("Find").clicked() { + action = Some(false); + } + if ui.button("Replace all").clicked() { + action = Some(true); + } + }); + }); + if let Some(rep) = action + && !find.is_empty() + { + let r = if rep { app.run("edit.findReplace", json!({"find": find, "replace": replace, "layerNames": false})) } else { app.run("edit.findReplace", json!({"find": find, "layerNames": true})) }; + if let Some(v) = r { + let n = v["matches"].as_array().map(|a| a.len()).unwrap_or(0); + app.toast(format!("{n} match{}", if n == 1 { "" } else { "es" })); + } + } + app.ui.find = if open { Some((find, replace)) } else { None }; + } + if ctx.data(|d| d.get_temp::(Id::new("variables_open"))).unwrap_or(false) { + variables(app, ctx); + } + if app.ui.export_preview { + export_dialog(app, ctx); + } +} + +fn variables(app: &mut FigApp, ctx: &Context) { + let t = Tokens::get(ctx); + let mut open = true; + let vars = app.session.doc().map(|d| d.variables.clone()).unwrap_or_default(); + egui::Window::new("Local variables").open(&mut open).default_size(vec2(640.0, 360.0)).show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("+ Collection").clicked() { + let n = vars.collections.len() + 1; + app.run("variable.createCollection", json!({"name": format!("Collection {n}"), "modes": ["Mode 1"]})); + } + }); + for c in &vars.collections { + ui.separator(); + ui.horizontal(|ui| { + ui.label(RichText::new(&c.name).font(semibold(12.0))); + if ui.small_button("+ Mode").clicked() { + app.run("variable.addMode", json!({"collection": c.id, "name": format!("Mode {}", c.modes.len() + 1)})); + } + for ty in ["color", "number", "string", "boolean"] { + if ui.small_button(format!("+ {ty}")).clicked() { + let n = vars.variables.len() + 1; + app.run("variable.create", json!({"collection": c.id, "name": format!("{ty}-{n}"), "type": ty})); + } + } + }); + egui::Grid::new(("vars", c.id.as_str())).striped(true).show(ui, |ui| { + ui.label(RichText::new("Name").color(t.text_secondary)); + for m in &c.modes { + ui.label(RichText::new(&m.name).color(t.text_secondary)); + } + ui.end_row(); + for v in vars.variables.iter().filter(|v| v.collection == c.id) { + ui.label(&v.name); + for m in &c.modes { + let cur = v.values.get(&m.id).map(|x| x.display()).unwrap_or_default(); + let id = Id::new(("varval", v.id.as_str(), m.id.as_str())); + if let Some(new) = crate::widgets::text_field(ui, id, &cur, 110.0, "") { + let val = match v.kind { + figcraft_doc::VariableType::Number => new.parse::().map(|x| json!(x)).unwrap_or(json!(0)), + figcraft_doc::VariableType::Boolean => json!(new.trim() == "true"), + _ => json!(new), + }; + app.run("variable.set", json!({"variable": v.id, "mode": m.id, "value": val})); + } + } + ui.end_row(); + } + }); + } + if vars.collections.is_empty() { + ui.label(RichText::new("Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark. Bind them to layer properties.").color(t.text_secondary)); + } + }); + if !open { + ctx.data_mut(|d| d.remove::(Id::new("variables_open"))); + } +} + +fn export_dialog(app: &mut FigApp, ctx: &Context) { + let t = Tokens::get(ctx); + let mut open = true; + let sel = app.session.selection(); + let page_children: Vec<_> = app.session.ds().ok().and_then(|d| d.doc.node(d.page).map(|p| p.children.clone())).unwrap_or_default(); + let ids = if sel.is_empty() { page_children } else { sel }; + let mut do_export: Option<(&str, f64)> = None; + window("Export", ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + ui.label(RichText::new(format!("{} layer{}", ids.len(), if ids.len() == 1 { "" } else { "s" })).color(t.text_secondary)); + ui.add_space(8.0); + for (label, fmt, scale) in [("PNG @1x", "png", 1.0), ("PNG @2x", "png", 2.0), ("PNG @3x", "png", 3.0), ("JPG @2x", "jpg", 2.0), ("WebP @2x", "webp", 2.0), ("SVG", "svg", 1.0)] { + let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text); + if resp.clicked() { + do_export = Some((fmt, scale)); + } + } + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(320.0, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); + }); + if let Some((fmt, scale)) = do_export { + let name = ids.first().and_then(|i| app.session.doc().ok()?.node(*i).map(|n| n.name.clone())).unwrap_or_else(|| "Export".into()); + let sfx = if fmt != "svg" && scale != 1.0 { format!("@{scale}x") } else { String::new() }; + if let Some(p) = app.services.pick_save("Export", &format!("{name}{sfx}.{fmt}"), &[fmt]) { + let ps = p.to_string_lossy().to_string(); + if app.run("file.export", json!({"ids": ids, "format": fmt, "scale": scale, "path": ps})).is_some() { + app.toast("Exported"); + } + } + open = false; + } + if !open { + app.ui.export_preview = false; + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs new file mode 100644 index 00000000..505060d9 --- /dev/null +++ b/crates/ui-egui/src/home.rs @@ -0,0 +1,115 @@ +//! The home screen: new file, open, recent files, templates. + +use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; + +pub fn show(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { + let rect = ui.max_rect(); + let left = Rect::from_min_size(rect.min, vec2(240.0, rect.height())); + ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border)); + // Sidebar. + let mut y = left.min.y + 20.0; + icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); + ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); + y += 48.0; + for (icon, label) in [(Icon::Home, "Recents"), (Icon::Page, "Drafts"), (Icon::Assets, "Community")] { + let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(224.0, 32.0)); + if label == "Recents" { + ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row); + } + icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, t.icon); + ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.text); + y += 34.0; + } + let main = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); + ui.painter().text(main.min, Align2::LEFT_TOP, "Recents", semibold(18.0), t.text); + // Action cards. + let mut x = main.min.x; + let cy = main.min.y + 48.0; + let mut new_file = false; + let mut open = false; + for (icon, title, sub) in [(Icon::Plus, "New design file", "Start from a blank canvas"), (Icon::Page, "Open file", "Open .figcraft, .svg or images")] { + let r = Rect::from_min_size(pos2(x, cy), vec2(260.0, 72.0)); + let resp = ui.interact(r, ui.id().with(("card", title)), Sense::click()); + ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); + ui.painter().rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside); + let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); + ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); + icons::paint(ui.painter(), ir.shrink(8.0), icon, egui::Color32::WHITE); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title, semibold(12.0), t.text); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, regular(11.0), t.text_secondary); + if resp.clicked() { + if title.starts_with("New") { + new_file = true; + } else { + open = true; + } + } + x += 280.0; + } + if new_file { + app.run("file.new", json!({})); + app.ui.home = false; + } + if open { + app.open_dialog(); + } + // Open documents and recent files. + let mut y = cy + 108.0; + ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Open files", semibold(12.0), t.text_secondary); + y += 28.0; + let mut switch = None; + for (i, d) in app.session.docs.iter().enumerate() { + let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 36.0)); + let resp = ui.interact(r, ui.id().with(("open", d.uid)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), Icon::Logo, ACCENT); + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, &d.doc.name, regular(12.0), t.text); + if let Some(p) = &d.path { + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, p, regular(11.0), t.text_secondary); + } + if resp.clicked() { + switch = Some(i); + } + y += 38.0; + } + if let Some(i) = switch { + app.run("doc.switch", json!({"index": i})); + app.ui.home = false; + } + y += 16.0; + let recents = app.services.recent_files(); + if !recents.is_empty() { + ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Recent files", semibold(12.0), t.text_secondary); + y += 28.0; + let mut open_path = None; + for p in recents.iter().take(12) { + let r = Rect::from_min_size(pos2(main.min.x, y), vec2(main.width().min(640.0), 32.0)); + let resp = ui.interact(r, ui.id().with(("recent", p.as_str())), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + let name = std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, p, regular(10.0), t.text_tertiary); + if resp.clicked() { + open_path = Some(p.clone()); + } + y += 34.0; + } + if let Some(p) = open_path + && app.run("file.open", json!({"path": p})).is_some() + { + app.ui.home = false; + } + } + }); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 00000000..750fa95d --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,622 @@ +//! FigCraft's icon set, drawn in code from simple primitives on a 16×16 grid. Original artwork +//! (MIT OR Apache-2.0): no third-party icons are used anywhere. + +use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Icon { + Move, + Scale, + Hand, + Frame, + Section, + Slice, + Rectangle, + Line, + Arrow, + Ellipse, + Polygon, + Star, + Image, + Pen, + Pencil, + Text, + Comment, + Actions, + Chevron, + ChevronRight, + ChevronLeft, + ChevronUp, + Plus, + Minus, + Eye, + EyeOff, + Lock, + Unlock, + Component, + Instance, + ComponentSet, + Group, + Vector, + Boolean, + Page, + Check, + Close, + Search, + Grid, + AlignLeft, + AlignHCenter, + AlignRight, + AlignTop, + AlignVCenter, + AlignBottom, + DistributeH, + DistributeV, + Rotate, + FlipH, + FlipV, + CornerRadius, + IndependentCorners, + LinkOn, + LinkOff, + LayoutH, + LayoutV, + LayoutWrap, + LayoutGrid, + AutoLayout, + ClipContent, + Effects, + Styles, + Menu, + Play, + Home, + Bell, + Panels, + Help, + Dots, + Mask, + TextAlignLeft, + TextAlignCenter, + TextAlignRight, + TextAlignJustify, + TextTop, + TextMiddle, + TextBottom, + Eyedropper, + Code, + Assets, + Layers, + Opacity, + Blend, + Export, + StrokeIcon, + Fill, + Sun, + Moon, + Undo, + Redo, + Union, + Subtract, + Intersect, + Exclude, + Hug, + FillContainer, + Fixed, + PaddingH, + PaddingV, + Gap, + Reset, + Detach, + Swap, + Link, + Prototype, + Section2, + Logo, +} + +struct P<'a> { + painter: &'a Painter, + o: Pos2, + s: f32, + stroke: Stroke, + color: Color32, +} + +impl P<'_> { + fn p(&self, x: f32, y: f32) -> Pos2 { + pos2(self.o.x + x * self.s, self.o.y + y * self.s) + } + fn line(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.painter.line_segment([self.p(x0, y0), self.p(x1, y1)], self.stroke); + } + fn poly(&self, pts: &[(f32, f32)], closed: bool) { + let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); + if closed { + self.painter.add(Shape::closed_line(v, self.stroke)); + } else { + self.painter.add(Shape::line(v, self.stroke)); + } + } + fn fill_poly(&self, pts: &[(f32, f32)]) { + let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); + self.painter.add(Shape::convex_polygon(v, self.color, Stroke::NONE)); + } + fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) { + let rr = Rect::from_min_max(self.p(x0, y0), self.p(x1, y1)); + self.painter.rect_stroke(rr, egui::CornerRadius::same((r * self.s) as u8), self.stroke, egui::StrokeKind::Middle); + } + fn fill_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32) { + let rr = Rect::from_min_max(self.p(x0, y0), self.p(x1, y1)); + self.painter.rect_filled(rr, egui::CornerRadius::same((r * self.s) as u8), self.color); + } + fn circle(&self, x: f32, y: f32, r: f32) { + self.painter.circle_stroke(self.p(x, y), r * self.s, self.stroke); + } + fn dot(&self, x: f32, y: f32, r: f32) { + self.painter.circle_filled(self.p(x, y), r * self.s, self.color); + } + fn arc(&self, cx: f32, cy: f32, r: f32, a0: f32, a1: f32) { + let n = 16; + let pts: Vec<(f32, f32)> = (0..=n).map(|i| { + let t = a0 + (a1 - a0) * i as f32 / n as f32; + (cx + r * t.cos(), cy + r * t.sin()) + }).collect(); + self.poly(&pts, false); + } +} + +/// Paint `icon` centred in `rect` (a 16-unit glyph scaled to fit) in `color`. +pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { + let size = rect.width().min(rect.height()); + let s = size / 16.0; + let o = rect.center() - vec2(8.0 * s, 8.0 * s); + let w = (1.0 * s).clamp(1.0, 2.0); + let p = P { painter, o, s, stroke: Stroke::new(w, color), color }; + use Icon::*; + match icon { + Move => p.fill_poly(&[(4.0, 2.5), (4.0, 13.0), (6.6, 10.6), (8.4, 14.2), (9.9, 13.5), (8.2, 10.0), (11.6, 9.8)]), + Scale => { + p.rect(2.5, 6.5, 9.5, 13.5, 0.0); + p.line(7.0, 9.0, 13.5, 2.5); + p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false); + } + Hand => { + p.poly(&[(5.0, 8.5), (5.0, 4.0), (6.5, 4.0), (6.5, 7.5)], false); + p.poly(&[(6.5, 7.5), (6.5, 2.8), (8.0, 2.8), (8.0, 7.5)], false); + p.poly(&[(8.0, 7.5), (8.0, 3.5), (9.5, 3.5), (9.5, 8.0)], false); + p.poly(&[(9.5, 8.0), (9.5, 5.0), (11.0, 5.0), (11.0, 10.0), (9.5, 13.5), (6.0, 13.5), (3.5, 9.5), (4.5, 8.5), (5.0, 8.5)], false); + } + Frame => { + p.line(5.0, 2.0, 5.0, 14.0); + p.line(11.0, 2.0, 11.0, 14.0); + p.line(2.0, 5.0, 14.0, 5.0); + p.line(2.0, 11.0, 14.0, 11.0); + } + Section | Section2 => { + p.rect(2.5, 4.5, 13.5, 13.5, 1.5); + p.fill_rect(2.5, 2.5, 8.5, 5.5, 1.0); + } + Slice => { + p.line(3.0, 13.0, 13.0, 3.0); + p.rect(2.5, 2.5, 13.5, 13.5, 0.0); + } + Rectangle => p.rect(3.0, 3.0, 13.0, 13.0, 0.5), + Line => p.line(3.0, 13.0, 13.0, 3.0), + Arrow => { + p.line(3.0, 13.0, 13.0, 3.0); + p.poly(&[(8.0, 3.0), (13.0, 3.0), (13.0, 8.0)], false); + } + Ellipse => p.circle(8.0, 8.0, 5.5), + Polygon => p.poly(&[(8.0, 2.5), (13.5, 12.5), (2.5, 12.5)], true), + Star => { + let pts: Vec<(f32, f32)> = (0..10) + .map(|i| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 5.0; + let r = if i % 2 == 0 { 6.0 } else { 2.6 }; + (8.0 + r * a.cos(), 8.5 + r * a.sin()) + }) + .collect(); + p.poly(&pts, true); + } + Image => { + p.rect(2.5, 3.5, 13.5, 12.5, 1.0); + p.poly(&[(2.5, 11.0), (6.0, 7.5), (9.0, 10.5), (10.5, 9.0), (13.5, 12.0)], false); + p.dot(10.5, 6.0, 1.0); + } + Pen => { + p.poly(&[(8.0, 2.0), (12.5, 9.0), (10.0, 13.5), (6.0, 13.5), (3.5, 9.0)], true); + p.line(8.0, 2.0, 8.0, 8.0); + p.dot(8.0, 9.0, 1.0); + } + Pencil => { + p.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], true); + p.line(9.5, 4.5, 11.5, 6.5); + } + Text => { + p.line(3.5, 3.5, 12.5, 3.5); + p.line(8.0, 3.5, 8.0, 13.0); + p.line(3.5, 3.5, 3.5, 5.0); + p.line(12.5, 3.5, 12.5, 5.0); + p.line(6.5, 13.0, 9.5, 13.0); + } + Comment => { + p.poly(&[(8.0, 2.5), (11.5, 3.2), (13.3, 6.3), (12.8, 9.8), (10.0, 12.8), (8.0, 13.2), (2.8, 13.2), (2.8, 8.0), (3.6, 4.8), (5.5, 3.1)], true); + } + Actions => { + p.poly(&[(9.0, 2.0), (4.0, 9.0), (8.0, 9.0), (7.0, 14.0), (12.0, 7.0), (8.0, 7.0)], true); + } + Chevron => p.poly(&[(5.0, 6.5), (8.0, 9.5), (11.0, 6.5)], false), + ChevronUp => p.poly(&[(5.0, 9.5), (8.0, 6.5), (11.0, 9.5)], false), + ChevronRight => p.poly(&[(6.5, 5.0), (9.5, 8.0), (6.5, 11.0)], false), + ChevronLeft => p.poly(&[(9.5, 5.0), (6.5, 8.0), (9.5, 11.0)], false), + Plus => { + p.line(8.0, 3.0, 8.0, 13.0); + p.line(3.0, 8.0, 13.0, 8.0); + } + Minus => p.line(3.0, 8.0, 13.0, 8.0), + Eye => { + p.arc(8.0, 12.5, 7.0, -2.5, -0.64); + p.arc(8.0, 3.5, 7.0, 0.64, 2.5); + p.circle(8.0, 8.0, 2.0); + } + EyeOff => { + p.arc(8.0, 12.5, 7.0, -2.5, -0.64); + p.arc(8.0, 3.5, 7.0, 0.64, 2.5); + p.line(3.0, 13.0, 13.0, 3.0); + } + Lock => { + p.rect(3.5, 7.5, 12.5, 13.5, 1.0); + p.arc(8.0, 7.5, 3.0, std::f32::consts::PI, std::f32::consts::TAU); + } + Unlock => { + p.rect(3.5, 7.5, 12.5, 13.5, 1.0); + p.arc(11.0, 7.5, 3.0, std::f32::consts::PI, std::f32::consts::TAU - 0.3); + } + Component => { + for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] { + p.fill_poly(&[(cx, cy - 2.4), (cx + 2.4, cy), (cx, cy + 2.4), (cx - 2.4, cy)]); + } + } + ComponentSet => { + for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] { + p.poly(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true); + } + } + Instance => p.poly(&[(8.0, 2.5), (13.5, 8.0), (8.0, 13.5), (2.5, 8.0)], true), + Group => { + for (x0, y0, x1, y1) in [(3.0, 3.0, 6.0, 3.0), (10.0, 3.0, 13.0, 3.0), (3.0, 13.0, 6.0, 13.0), (10.0, 13.0, 13.0, 13.0)] { + p.line(x0, y0, x1, y1); + } + for (x0, y0, x1, y1) in [(3.0, 3.0, 3.0, 6.0), (13.0, 3.0, 13.0, 6.0), (3.0, 10.0, 3.0, 13.0), (13.0, 10.0, 13.0, 13.0)] { + p.line(x0, y0, x1, y1); + } + } + Vector => { + p.poly(&[(3.0, 12.0), (6.0, 4.0), (10.0, 12.0), (13.0, 4.0)], false); + p.dot(3.0, 12.0, 1.2); + p.dot(13.0, 4.0, 1.2); + } + Boolean | Union => { + p.rect(2.5, 2.5, 9.5, 9.5, 0.0); + p.rect(6.5, 6.5, 13.5, 13.5, 0.0); + } + Subtract => { + p.fill_rect(2.5, 2.5, 9.5, 9.5, 0.0); + p.rect(6.5, 6.5, 13.5, 13.5, 0.0); + } + Intersect => { + p.rect(2.5, 2.5, 9.5, 9.5, 0.0); + p.rect(6.5, 6.5, 13.5, 13.5, 0.0); + p.fill_rect(6.5, 6.5, 9.5, 9.5, 0.0); + } + Exclude => { + p.fill_rect(2.5, 2.5, 9.5, 9.5, 0.0); + p.fill_rect(6.5, 6.5, 13.5, 13.5, 0.0); + } + Page => { + p.poly(&[(4.0, 2.5), (9.5, 2.5), (12.5, 5.5), (12.5, 13.5), (4.0, 13.5)], true); + p.poly(&[(9.5, 2.5), (9.5, 5.5), (12.5, 5.5)], false); + } + Check => p.poly(&[(3.5, 8.5), (6.5, 11.5), (12.5, 4.5)], false), + Close => { + p.line(4.0, 4.0, 12.0, 12.0); + p.line(12.0, 4.0, 4.0, 12.0); + } + Search => { + p.circle(7.0, 7.0, 4.0); + p.line(10.0, 10.0, 13.5, 13.5); + } + Grid | LayoutGrid => { + for i in [3.0, 6.5, 10.0] { + for j in [3.0, 6.5, 10.0] { + p.rect(i, j, i + 2.5, j + 2.5, 0.0); + } + } + } + AlignLeft => { + p.line(2.5, 2.0, 2.5, 14.0); + p.fill_rect(4.5, 4.0, 12.0, 6.5, 0.5); + p.fill_rect(4.5, 9.5, 9.0, 12.0, 0.5); + } + AlignHCenter => { + p.line(8.0, 2.0, 8.0, 14.0); + p.fill_rect(3.5, 4.0, 12.5, 6.5, 0.5); + p.fill_rect(5.5, 9.5, 10.5, 12.0, 0.5); + } + AlignRight => { + p.line(13.5, 2.0, 13.5, 14.0); + p.fill_rect(4.0, 4.0, 11.5, 6.5, 0.5); + p.fill_rect(7.0, 9.5, 11.5, 12.0, 0.5); + } + AlignTop => { + p.line(2.0, 2.5, 14.0, 2.5); + p.fill_rect(4.0, 4.5, 6.5, 12.0, 0.5); + p.fill_rect(9.5, 4.5, 12.0, 9.0, 0.5); + } + AlignVCenter => { + p.line(2.0, 8.0, 14.0, 8.0); + p.fill_rect(4.0, 3.5, 6.5, 12.5, 0.5); + p.fill_rect(9.5, 5.5, 12.0, 10.5, 0.5); + } + AlignBottom => { + p.line(2.0, 13.5, 14.0, 13.5); + p.fill_rect(4.0, 4.0, 6.5, 11.5, 0.5); + p.fill_rect(9.5, 7.0, 12.0, 11.5, 0.5); + } + DistributeH | Gap => { + p.line(2.5, 3.0, 2.5, 13.0); + p.line(13.5, 3.0, 13.5, 13.0); + p.fill_rect(6.5, 5.0, 9.5, 11.0, 0.5); + } + DistributeV => { + p.line(3.0, 2.5, 13.0, 2.5); + p.line(3.0, 13.5, 13.0, 13.5); + p.fill_rect(5.0, 6.5, 11.0, 9.5, 0.5); + } + Rotate => { + p.arc(8.0, 8.0, 5.0, -2.6, 2.2); + p.poly(&[(3.0, 2.5), (3.4, 5.5), (6.4, 5.0)], false); + } + FlipH => { + p.line(8.0, 2.0, 8.0, 14.0); + p.poly(&[(6.0, 4.0), (2.5, 12.0), (6.0, 12.0)], true); + p.fill_poly(&[(10.0, 4.0), (13.5, 12.0), (10.0, 12.0)]); + } + FlipV => { + p.line(2.0, 8.0, 14.0, 8.0); + p.poly(&[(4.0, 6.0), (12.0, 2.5), (12.0, 6.0)], true); + p.fill_poly(&[(4.0, 10.0), (12.0, 13.5), (12.0, 10.0)]); + } + CornerRadius => p.poly(&[(3.0, 13.0), (3.0, 8.0), (3.4, 5.6), (5.6, 3.4), (8.0, 3.0), (13.0, 3.0)], false), + IndependentCorners => { + for (a, b, c) in [((2.5, 6.0), (2.5, 2.5), (6.0, 2.5)), ((10.0, 2.5), (13.5, 2.5), (13.5, 6.0)), ((13.5, 10.0), (13.5, 13.5), (10.0, 13.5)), ((6.0, 13.5), (2.5, 13.5), (2.5, 10.0))] { + p.poly(&[a, b, c], false); + } + } + LinkOn | Link => { + p.rect(2.5, 6.0, 8.5, 10.0, 2.0); + p.rect(7.5, 6.0, 13.5, 10.0, 2.0); + } + LinkOff => { + p.rect(2.5, 6.0, 7.0, 10.0, 2.0); + p.rect(9.0, 6.0, 13.5, 10.0, 2.0); + } + LayoutH => { + p.fill_rect(2.5, 4.0, 6.5, 12.0, 1.0); + p.fill_rect(9.5, 4.0, 13.5, 12.0, 1.0); + } + LayoutV => { + p.fill_rect(4.0, 2.5, 12.0, 6.5, 1.0); + p.fill_rect(4.0, 9.5, 12.0, 13.5, 1.0); + } + LayoutWrap => { + p.poly(&[(2.5, 4.5), (12.0, 4.5), (13.5, 6.0), (13.5, 8.0), (12.0, 9.5), (4.0, 9.5)], false); + p.poly(&[(6.0, 7.5), (4.0, 9.5), (6.0, 11.5)], false); + } + AutoLayout => { + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.fill_rect(5.0, 5.0, 7.0, 11.0, 0.5); + p.fill_rect(9.0, 5.0, 11.0, 11.0, 0.5); + } + ClipContent => { + p.rect(3.5, 3.5, 12.5, 12.5, 0.5); + p.line(5.0, 1.5, 5.0, 3.5); + p.line(1.5, 5.0, 3.5, 5.0); + } + Effects => { + p.circle(8.0, 8.0, 3.0); + for i in 0..8 { + let a = i as f32 * std::f32::consts::FRAC_PI_4; + p.line(8.0 + 4.8 * a.cos(), 8.0 + 4.8 * a.sin(), 8.0 + 6.2 * a.cos(), 8.0 + 6.2 * a.sin()); + } + } + Styles => { + for (x, y) in [(5.0, 5.0), (11.0, 5.0), (5.0, 11.0), (11.0, 11.0)] { + p.dot(x, y, 2.0); + } + } + Menu => { + p.line(3.0, 4.5, 13.0, 4.5); + p.line(3.0, 8.0, 13.0, 8.0); + p.line(3.0, 11.5, 13.0, 11.5); + } + Play => p.fill_poly(&[(5.0, 3.0), (13.0, 8.0), (5.0, 13.0)]), + Home => { + p.poly(&[(2.5, 7.5), (8.0, 2.5), (13.5, 7.5)], false); + p.poly(&[(4.0, 6.5), (4.0, 13.5), (12.0, 13.5), (12.0, 6.5)], false); + } + Bell => { + p.poly(&[(4.0, 11.0), (4.0, 7.0), (5.5, 4.0), (8.0, 3.0), (10.5, 4.0), (12.0, 7.0), (12.0, 11.0), (13.0, 12.0), (3.0, 12.0), (4.0, 11.0)], false); + p.arc(8.0, 12.5, 1.5, 0.0, std::f32::consts::PI); + } + Panels => { + p.rect(2.5, 3.5, 13.5, 12.5, 1.0); + p.line(5.5, 3.5, 5.5, 12.5); + p.line(10.5, 3.5, 10.5, 12.5); + } + Help => { + p.circle(8.0, 8.0, 6.0); + p.arc(8.0, 6.5, 2.0, std::f32::consts::PI, std::f32::consts::TAU + 0.6); + p.line(8.0, 8.4, 8.0, 9.5); + p.dot(8.0, 11.5, 0.8); + } + Dots => { + for x in [4.0, 8.0, 12.0] { + p.dot(x, 8.0, 1.1); + } + } + Mask => { + p.circle(8.0, 8.0, 5.5); + p.fill_poly(&[(8.0, 2.5), (10.5, 3.1), (12.4, 4.8), (13.5, 8.0), (12.4, 11.2), (10.5, 12.9), (8.0, 13.5)]); + } + TextAlignLeft => { + for (y, x1) in [(4.0, 13.0), (7.0, 10.0), (10.0, 13.0), (13.0, 9.0)] { + p.line(3.0, y, x1, y); + } + } + TextAlignCenter => { + for (y, h) in [(4.0, 5.0), (7.0, 3.5), (10.0, 5.0), (13.0, 3.0)] { + p.line(8.0 - h, y, 8.0 + h, y); + } + } + TextAlignRight => { + for (y, x0) in [(4.0, 3.0), (7.0, 6.0), (10.0, 3.0), (13.0, 7.0)] { + p.line(x0, y, 13.0, y); + } + } + TextAlignJustify => { + for y in [4.0, 7.0, 10.0, 13.0] { + p.line(3.0, y, 13.0, y); + } + } + TextTop => { + p.line(3.0, 3.0, 13.0, 3.0); + p.line(8.0, 5.5, 8.0, 13.0); + p.poly(&[(5.5, 8.0), (8.0, 5.5), (10.5, 8.0)], false); + } + TextMiddle => { + p.line(3.0, 8.0, 13.0, 8.0); + p.poly(&[(6.0, 3.0), (8.0, 5.5), (10.0, 3.0)], false); + p.poly(&[(6.0, 13.0), (8.0, 10.5), (10.0, 13.0)], false); + } + TextBottom => { + p.line(3.0, 13.0, 13.0, 13.0); + p.line(8.0, 3.0, 8.0, 10.5); + p.poly(&[(5.5, 8.0), (8.0, 10.5), (10.5, 8.0)], false); + } + Eyedropper => { + p.poly(&[(3.0, 13.0), (3.5, 10.5), (9.0, 5.0), (11.0, 7.0), (5.5, 12.5)], true); + p.line(9.5, 3.0, 13.0, 6.5); + p.line(8.0, 4.0, 12.0, 8.0); + } + Code => { + p.poly(&[(5.5, 4.5), (2.5, 8.0), (5.5, 11.5)], false); + p.poly(&[(10.5, 4.5), (13.5, 8.0), (10.5, 11.5)], false); + } + Assets => { + p.fill_poly(&[(8.0, 2.5), (10.5, 5.0), (8.0, 7.5), (5.5, 5.0)]); + p.poly(&[(3.0, 8.5), (5.5, 11.0), (3.0, 13.5), (0.5, 11.0)], true); + p.rect(9.5, 9.0, 14.0, 13.5, 0.5); + } + Layers => { + p.poly(&[(8.0, 2.5), (14.0, 5.5), (8.0, 8.5), (2.0, 5.5)], true); + p.poly(&[(2.0, 8.5), (8.0, 11.5), (14.0, 8.5)], false); + } + Opacity => { + p.circle(8.0, 8.0, 5.5); + p.fill_poly(&[(8.0, 2.5), (8.0, 13.5), (5.5, 12.9), (3.6, 11.2), (2.5, 8.0), (3.6, 4.8), (5.5, 3.1)]); + } + Blend => { + p.circle(6.0, 8.0, 3.8); + p.circle(10.0, 8.0, 3.8); + } + Export => { + p.line(8.0, 2.5, 8.0, 10.0); + p.poly(&[(5.0, 5.5), (8.0, 2.5), (11.0, 5.5)], false); + p.poly(&[(3.0, 9.0), (3.0, 13.5), (13.0, 13.5), (13.0, 9.0)], false); + } + StrokeIcon => p.rect(3.0, 3.0, 13.0, 13.0, 1.5), + Fill => p.fill_rect(3.0, 3.0, 13.0, 13.0, 1.5), + Sun => { + p.circle(8.0, 8.0, 2.8); + for i in 0..8 { + let a = i as f32 * std::f32::consts::FRAC_PI_4; + p.line(8.0 + 4.5 * a.cos(), 8.0 + 4.5 * a.sin(), 8.0 + 6.0 * a.cos(), 8.0 + 6.0 * a.sin()); + } + } + Moon => p.arc(8.0, 8.0, 5.0, 0.9, 5.4), + Undo => { + p.arc(9.0, 9.0, 4.5, -2.6, 1.6); + p.poly(&[(3.0, 3.5), (4.0, 7.0), (7.3, 6.0)], false); + } + Redo => { + p.arc(7.0, 9.0, 4.5, 1.5, 5.7); + p.poly(&[(13.0, 3.5), (12.0, 7.0), (8.7, 6.0)], false); + } + Hug => { + p.poly(&[(2.5, 5.0), (5.0, 8.0), (2.5, 11.0)], false); + p.poly(&[(13.5, 5.0), (11.0, 8.0), (13.5, 11.0)], false); + p.line(5.0, 8.0, 11.0, 8.0); + } + FillContainer => { + p.poly(&[(5.0, 5.0), (2.5, 8.0), (5.0, 11.0)], false); + p.poly(&[(11.0, 5.0), (13.5, 8.0), (11.0, 11.0)], false); + p.line(2.5, 8.0, 13.5, 8.0); + } + Fixed => { + p.line(2.5, 4.0, 2.5, 12.0); + p.line(13.5, 4.0, 13.5, 12.0); + p.line(2.5, 8.0, 13.5, 8.0); + } + PaddingH => { + p.rect(2.5, 3.5, 13.5, 12.5, 1.0); + p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.5); + } + PaddingV => { + p.rect(3.5, 2.5, 12.5, 13.5, 1.0); + p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.5); + } + Reset => { + p.arc(8.0, 8.0, 5.0, -2.2, 2.6); + p.poly(&[(2.8, 2.8), (3.3, 5.8), (6.3, 5.3)], false); + } + Detach => { + p.poly(&[(8.0, 2.5), (13.5, 8.0), (8.0, 13.5), (2.5, 8.0)], true); + p.line(4.0, 12.0, 12.0, 4.0); + } + Swap => { + p.poly(&[(3.0, 5.5), (12.0, 5.5), (10.0, 3.5)], false); + p.poly(&[(13.0, 10.5), (4.0, 10.5), (6.0, 12.5)], false); + } + Prototype => { + p.circle(4.5, 11.5, 2.0); + p.poly(&[(6.0, 10.0), (9.0, 6.0), (12.5, 4.0)], false); + p.poly(&[(10.0, 3.0), (12.8, 3.8), (12.0, 6.6)], false); + } + Logo => { + // FigCraft's own mark: three stacked rounded tiles forming an "F" shape. + p.fill_rect(3.0, 2.0, 13.0, 5.5, 1.75); + p.fill_rect(3.0, 6.25, 10.5, 9.75, 1.75); + p.fill_rect(3.0, 10.5, 6.5, 14.0, 1.75); + } + } +} + +/// Allocate a square icon button; returns the response. `active` paints the selected look. +pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, active: bool, tooltip: &str) -> egui::Response { + let t = crate::theme::Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), egui::Sense::click()); + let bg = if active { + t.selected_row + } else if resp.is_pointer_button_down_on() { + t.pressed + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), bg); + let col = if active { t.accent_text } else { t.icon }; + paint(ui.painter(), rect.shrink(size * 0.17), icon, col); + if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) } +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs new file mode 100644 index 00000000..160657fc --- /dev/null +++ b/crates/ui-egui/src/layers.rs @@ -0,0 +1,515 @@ +//! The left panel: file header, File/Assets tabs, pages and the layers tree. + +use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::json; + +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold}; +use crate::{FigApp, LeftTab}; + +pub const ROW: f32 = 32.0; + +pub fn node_icon(doc: &Document, id: NodeId) -> Icon { + let Some(n) = doc.node(id) else { return Icon::Rectangle }; + match &n.kind { + NodeKind::Page { .. } => Icon::Page, + NodeKind::Frame => { + if let Some(l) = &n.layout { + match l.mode { + figcraft_doc::LayoutMode::Vertical => Icon::LayoutV, + figcraft_doc::LayoutMode::Grid => Icon::LayoutGrid, + _ => Icon::LayoutH, + } + } else { + Icon::Frame + } + } + NodeKind::Group => Icon::Group, + NodeKind::Section { .. } => Icon::Section, + NodeKind::Rectangle => { + if n.fills.iter().any(|f| matches!(f.kind, figcraft_color::PaintKind::Image(_))) { + Icon::Image + } else { + Icon::Rectangle + } + } + NodeKind::Ellipse { .. } => Icon::Ellipse, + NodeKind::Polygon { .. } => Icon::Polygon, + NodeKind::Star { .. } => Icon::Star, + NodeKind::Line => { + if n.stroke.cap_end.is_arrow() { + Icon::Arrow + } else { + Icon::Line + } + } + NodeKind::Vector { .. } => Icon::Vector, + NodeKind::Text(_) => Icon::Text, + NodeKind::BooleanOp { op } => match op { + figcraft_doc::BoolOp::Union => Icon::Union, + figcraft_doc::BoolOp::Subtract => Icon::Subtract, + figcraft_doc::BoolOp::Intersect => Icon::Intersect, + figcraft_doc::BoolOp::Exclude => Icon::Exclude, + }, + NodeKind::Component { .. } => Icon::Component, + NodeKind::ComponentSet { .. } => Icon::ComponentSet, + NodeKind::Instance { .. } => Icon::Instance, + NodeKind::Slice => Icon::Slice, + } +} + +pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let full = ui.max_rect(); + // Header: logo (main menu) + file name. + let (head, _) = ui.allocate_exact_size(vec2(full.width(), 48.0), Sense::hover()); + let lr = Rect::from_min_size(pos2(head.min.x + 8.0, head.min.y + 8.0), vec2(48.0, 32.0)); + let lresp = ui.interact(lr, ui.id().with("mainmenu"), Sense::click()).on_hover_text("Main menu"); + if lresp.hovered() || app.ui.main_menu { + ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), Rect::from_min_size(pos2(lr.min.x + 6.0, lr.center().y - 9.0), vec2(18.0, 18.0)), Icon::Logo, ACCENT); + icons::paint(ui.painter(), Rect::from_min_size(pos2(lr.min.x + 28.0, lr.center().y - 6.0), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + if lresp.clicked() { + app.ui.main_menu = !app.ui.main_menu; + } + let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); + let nr = Rect::from_min_max(pos2(lr.max.x + 6.0, head.min.y + 6.0), pos2(head.max.x - 40.0, head.min.y + 26.0)); + if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(12.0)) { + app.run("file.rename", json!({"name": new})); + } + ui.painter().text(pos2(nr.min.x + 2.0, head.min.y + 34.0), Align2::LEFT_CENTER, "Drafts", regular(11.0), t.text_secondary); + // Hide UI toggle. + let hr = Rect::from_center_size(pos2(head.max.x - 22.0, head.center().y), vec2(24.0, 24.0)); + let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).on_hover_text("Minimize UI ⇧⌘\\"); + if hresp.hovered() { + ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon); + if hresp.clicked() { + app.run("view.toggleUi", json!({})); + } + // Tabs row. + let (tabs, _) = ui.allocate_exact_size(vec2(full.width(), 40.0), Sense::hover()); + ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border)); + let mut x = tabs.min.x + 8.0; + for (tab, label) in [(LeftTab::File, "File"), (LeftTab::Assets, "Assets")] { + let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); + let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let resp = ui.interact(r, ui.id().with(("ltab", label)), Sense::click()); + let active = app.ui.left_tab == tab; + if active { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary }); + if resp.clicked() { + app.ui.left_tab = tab; + } + x = r.max.x + 2.0; + } + let sr = Rect::from_center_size(pos2(tabs.max.x - 22.0, tabs.center().y), vec2(24.0, 24.0)); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).on_hover_text("Find layers ⌘F"); + if sresp.hovered() { + ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + if sresp.clicked() { + app.ui.find = Some((String::new(), String::new())); + } + ui.painter().line_segment([tabs.left_bottom(), tabs.right_bottom()], Stroke::new(1.0, t.border)); + match app.ui.left_tab { + LeftTab::File => { + pages(app, ui); + layers(app, ui); + } + LeftTab::Assets => assets(app, ui), + } +} + +fn pages(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Pages", semibold(11.0), t.text); + if tresp.clicked() { + app.ui.pages_open = !app.ui.pages_open; + } + let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); + let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).on_hover_text("Add new page"); + if aresp.hovered() { + ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); + if aresp.clicked() { + app.run("page.add", json!({})); + } + if !app.ui.pages_open { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let pages: Vec<(NodeId, String)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone()))).collect(); + let cur = ds.page; + let mut select = None; + let mut rename_done: Option<(NodeId, String)> = None; + for (id, name) in pages { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); + let r = r.shrink2(vec2(8.0, 0.0)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + if id == cur { + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.icon); + } + if app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id) { + let tr = Rect::from_min_max(pos2(r.min.x + 28.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) { + rename_done = Some((id, new)); + } + } else { + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, if id == cur { semibold(11.0) } else { regular(11.0) }, t.text); + } + if resp.clicked() { + select = Some(id); + } + if resp.double_clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); + } + resp.context_menu(|ui| { + if ui.button("Rename page").clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button("Duplicate page").clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button("Delete page").clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + if let Some((id, new)) = rename_done { + app.ui.rename_page = None; + app.run("page.rename", json!({"id": id, "name": new})); + } + if let Some(p) = select + && p != cur + { + app.run("page.select", json!({"id": p})); + } + crate::widgets::separator(ui); +} + +struct Row { + id: NodeId, + depth: usize, + has_kids: bool, +} + +fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &std::collections::BTreeSet, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + // Layers panel lists top of the stack first. + for c in n.children.iter().rev() { + let Some(cn) = doc.node(*c) else { continue }; + let has_kids = !cn.children.is_empty() && !matches!(cn.kind, NodeKind::BooleanOp { .. }) || matches!(cn.kind, NodeKind::BooleanOp { .. }); + out.push(Row { id: *c, depth, has_kids }); + if expanded.contains(c) && depth < 64 { + collect(doc, *c, depth + 1, expanded, out); + } + } +} + +fn layers(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Layers", semibold(11.0), t.text); + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let page = ds.page; + let sel = ds.selection.clone(); + let hover = ds.hover; + // Auto-expand ancestors of the selection. + for s in &sel { + for a in doc.ancestors(*s) { + if a != page { + app.ui.expanded.insert(a); + } + } + } + let mut rows = Vec::new(); + collect(&doc, page, 0, &app.ui.expanded, &mut rows); + let mut clicked: Option<(NodeId, egui::Modifiers)> = None; + let mut toggle: Option = None; + let mut vis: Option = None; + let mut lock: Option = None; + let mut rename_done: Option<(NodeId, String)> = None; + let mut hovered_row: Option = None; + let drag_id = Id::new("layer_drag"); + let mut drop: Option<(NodeId, i8)> = None; + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + if rows.is_empty() { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + crate::widgets::label(ui, "Add a frame (F) or a shape (R) to get started", t.text_secondary); + }); + } + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + for row in &rows { + let Some(n) = doc.node(row.id) else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let selected = sel.contains(&row.id); + let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a)); + let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); + let rr = r.shrink2(vec2(8.0, 0.0)); + if selected { + ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row }); + } else if in_selected { + ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child); + } else if resp.hovered() || hover == Some(row.id) { + ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside); + } + if resp.hovered() { + hovered_row = Some(row.id); + } + let x0 = rr.min.x + 4.0 + row.depth as f32 * 16.0; + // Expand triangle. + if row.has_kids { + let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click()); + let open = app.ui.expanded.contains(&row.id); + icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); + if tresp.clicked() { + toggle = Some(row.id); + } + } + let icon_col = if is_comp { + COMPONENT + } else if n.visible { + t.icon + } else { + t.text_tertiary + }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), node_icon(&doc, row.id), icon_col); + let name_x = x0 + 36.0; + let editing = app.ui.rename.as_ref().is_some_and(|(i, _)| *i == row.id); + if editing { + let tr = Rect::from_min_max(pos2(name_x - 4.0, r.min.y + 4.0), pos2(rr.max.x - 4.0, r.max.y - 4.0)); + if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("rename", row.id)), &n.name, tr, regular(11.0)) { + rename_done = Some((row.id, new)); + } + } else { + let col = if is_comp { + COMPONENT + } else if n.visible { + t.text + } else { + t.text_tertiary + }; + let font = if doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()) && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { + semibold(11.0) + } else { + regular(11.0) + }; + let name = if n.name.is_empty() { n.kind.label().to_string() } else { n.name.clone() }; + let max_w = (rr.max.x - 56.0 - name_x).max(20.0); + let galley = ui.painter().layout(name, font, col, max_w); + ui.painter().with_clip_rect(Rect::from_min_max(pos2(name_x, r.min.y), pos2(rr.max.x - 52.0, r.max.y))).galley(pos2(name_x, r.center().y - galley.size().y / 2.0), galley, col); + } + // Lock / eye on hover (or when set). + let show_lock = resp.hovered() || n.locked; + let show_eye = resp.hovered() || !n.visible; + let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); + let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); + if show_eye { + let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click()); + icons::paint(ui.painter(), er.shrink(3.0), if n.visible { Icon::Eye } else { Icon::EyeOff }, t.icon_secondary); + if eresp.clicked() { + vis = Some(row.id); + } + } + if show_lock { + let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click()); + icons::paint(ui.painter(), lr.shrink(3.0), if n.locked { Icon::Lock } else { Icon::Unlock }, t.icon_secondary); + if lresp.clicked() { + lock = Some(row.id); + } + } + if resp.clicked() { + clicked = Some((row.id, ui.input(|i| i.modifiers))); + } + if resp.double_clicked() { + app.ui.rename = Some((row.id, n.name.clone())); + ui.memory_mut(|m| m.request_focus(Id::new(("rename", row.id)).with("te"))); + } + if resp.secondary_clicked() { + if !sel.contains(&row.id) { + clicked = Some((row.id, egui::Modifiers::NONE)); + } + let pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(r.center()); + app.ui.context_menu = Some((pos, vec![row.id])); + } + // Drag to reorder. + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(drag_id, row.id)); + } + if let Some(dg) = dragging + && dg != row.id + && let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && r.contains(p) + { + let rel = (p.y - r.min.y) / r.height(); + let into = n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }) && (0.25..0.75).contains(&rel); + let zone: i8 = if into { + 0 + } else if rel < 0.5 { + -1 + } else { + 1 + }; + let y = match zone { + -1 => r.min.y, + 1 => r.max.y, + _ => r.center().y, + }; + if zone == 0 { + ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + } else { + ui.painter().line_segment([pos2(x0, y), pos2(rr.max.x, y)], Stroke::new(2.0, ACCENT)); + } + if ui.input(|i| i.pointer.any_released()) { + drop = Some((row.id, zone)); + } + } + } + // Empty space below clears selection. + let rest = ui.available_rect_before_wrap(); + if rest.height() > 0.0 { + let resp = ui.allocate_rect(rest, Sense::click()); + if resp.clicked() { + app.run("select.none", json!({})); + } + } + }); + if ui.input(|i| i.pointer.any_released()) { + let dragged: Option = ui.data(|d| d.get_temp(drag_id)); + ui.data_mut(|d| d.remove::(drag_id)); + if let (Some(dg), Some((target, zone))) = (dragged, drop) { + let ids = if sel.contains(&dg) { sel.clone() } else { vec![dg] }; + match zone { + 0 => { + app.run("node.reparent", json!({"ids": ids, "parent": target})); + } + z => { + // Above in the list = higher z = after the target among siblings. + if let Some(parent) = doc.node(target).and_then(|n| n.parent) { + let idx = doc.index_in_parent(target).unwrap_or(0); + let index = if z < 0 { idx + 1 } else { idx }; + app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": index})); + } + } + } + } + } + if let Ok(ds) = app.session.ds_mut() { + let h = hovered_row; + if h.is_some() && ds.hover != h { + ds.hover = h; + } + } + if let Some(id) = toggle { + if !app.ui.expanded.remove(&id) { + app.ui.expanded.insert(id); + } + } + if let Some(id) = vis { + app.run("object.toggleVisible", json!({"ids": [id]})); + } + if let Some(id) = lock { + app.run("object.toggleLock", json!({"ids": [id]})); + } + if let Some((id, new)) = rename_done { + app.ui.rename = None; + app.run("object.rename", json!({"ids": [id], "name": new})); + } + if let Some((id, m)) = clicked { + if m.shift || m.command { + let mut s = sel.clone(); + if let Some(pos) = s.iter().position(|x| *x == id) { + s.remove(pos); + } else { + s.push(id); + } + app.run("select.set", json!({"ids": s})); + } else { + app.run("select.set", json!({"ids": [id]})); + } + if let Ok(ds) = app.session.ds_mut() + && ds.text_edit.is_some() + { + let _ = figcraft_engine::textedit::end(&mut app.session); + } + } + let _ = Color32::TRANSPARENT; +} + +fn assets(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let Ok(doc) = app.session.doc() else { return }; + let comps: Vec<(NodeId, String, bool)> = doc + .nodes() + .filter(|n| matches!(n.kind, NodeKind::Component { .. }) && !doc.node(n.parent.unwrap_or(NodeId(0))).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })) || matches!(n.kind, NodeKind::ComponentSet { .. })) + .map(|n| (n.id, n.name.clone(), matches!(n.kind, NodeKind::ComponentSet { .. }))) + .collect(); + let styles: Vec<(String, String, String)> = doc.styles.iter().map(|s| (s.id.clone(), s.name.clone(), s.kind.type_name().to_string())).collect(); + let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Local components", semibold(11.0), t.text); + let mut place = None; + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + if comps.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + crate::widgets::label(ui, "No components yet. Select layers and press ⌥⌘K.", t.text_secondary); + }); + } + for (id, name, set) in &comps { + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click()); + let rr = r.shrink2(vec2(8.0, 0.0)); + if resp.hovered() { + ui.painter().rect_filled(rr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(rr.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), if *set { Icon::ComponentSet } else { Icon::Component }, COMPONENT); + ui.painter().text(pos2(rr.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.0), t.text); + let resp = resp.on_hover_text("Click to place an instance at the centre of the view"); + if resp.clicked() { + place = Some((*id, *set)); + } + } + ui.add_space(8.0); + crate::widgets::separator(ui); + let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Local styles", semibold(11.0), t.text); + for (_, name, ty) in &styles { + let (r, _) = ui.allocate_exact_size(vec2(w, 28.0), Sense::hover()); + let icon = match ty.as_str() { + "text" => Icon::Text, + "effect" => Icon::Effects, + "grid" => Icon::Grid, + _ => Icon::Fill, + }; + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 22.0, r.center().y), vec2(14.0, 14.0)), icon, t.icon_secondary); + ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.0), t.text); + } + }); + if let Some((id, set)) = place { + let comp = if set { app.session.doc().ok().and_then(|d| d.node(id)).and_then(|n| n.children.first().copied()).unwrap_or(id) } else { id }; + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + app.run("instance.create", json!({"component": comp, "x": cx, "y": cy})); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 00000000..3d4bc484 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,376 @@ +//! The FigCraft egui frontend. +//! +//! A thin shell over [`figcraft_engine::Session`]: panels read engine state and act by running +//! commands; the canvas forwards pointer events to the engine's tools and draws overlays. Nothing +//! below this crate knows about egui, so the frontend can be replaced. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod canvas; +pub mod colorpicker; +pub mod control; +pub mod credits; +pub mod dialogs; +pub mod home; +pub mod icons; +pub mod layers; +pub mod menus; +pub mod present; +pub mod render_worker; +pub mod right; +pub mod shortcuts; +pub mod tabs; +pub mod theme; +pub mod toolbar; +pub mod widgets; + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +pub use control::{ControlReply, ControlRequest}; + +/// Platform services the app crate provides (file dialogs, clipboard, URLs). +pub trait Services { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { + None + } + fn clipboard_set_text(&mut self, _s: &str) {} + fn clipboard_get_text(&mut self) -> Option { + None + } + fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} + fn open_url(&mut self, _url: &str) {} + /// Recently opened files (persisted by the app). + fn recent_files(&self) -> Vec { + Vec::new() + } + fn add_recent(&mut self, _path: &str) {} +} + +/// No-op services (tests, headless screenshots). +pub struct NoServices; +impl Services for NoServices {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum RightTab { + Design, + Prototype, + Inspect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum LeftTab { + File, + Assets, +} + +/// UI-only state (serialisable for `ui.inspect`). +pub struct UiState { + pub left_width: f32, + pub right_width: f32, + pub right_tab: RightTab, + pub left_tab: LeftTab, + pub pages_open: bool, + pub expanded: BTreeSet, + pub rename: Option<(NodeId, String)>, + pub rename_page: Option<(NodeId, String)>, + pub main_menu: bool, + pub palette: Option, + pub about: bool, + pub preferences: bool, + pub shortcuts_panel: bool, + pub home: bool, + pub toast: Option<(String, f64)>, + pub present: Option, + pub picker: Option, + pub context_menu: Option<(egui::Pos2, Vec)>, + pub layer_filter: String, + pub dark: bool, + pub follow_system: bool, + pub tool_flyout: Option<&'static str>, + pub last_error: Option, + pub screenshot_requests: Vec<(u64, String)>, + pub frame_ms: f64, + pub render_ms: f64, + pub quit: bool, + pub export_preview: bool, + pub zoom_menu: bool, + pub find: Option<(String, String)>, +} + +impl Default for UiState { + fn default() -> Self { + Self { + left_width: 240.0, + right_width: 240.0, + right_tab: RightTab::Design, + left_tab: LeftTab::File, + pages_open: true, + expanded: BTreeSet::new(), + rename: None, + rename_page: None, + main_menu: false, + palette: None, + about: false, + preferences: false, + shortcuts_panel: false, + home: false, + toast: None, + present: None, + picker: None, + context_menu: None, + layer_filter: String::new(), + dark: false, + follow_system: false, + tool_flyout: None, + last_error: None, + screenshot_requests: Vec::new(), + frame_ms: 0.0, + render_ms: 0.0, + quit: false, + export_preview: false, + zoom_menu: false, + find: None, + } + } +} + +/// The whole FigCraft UI. +pub struct FigApp { + pub session: Session, + pub ui: UiState, + pub canvas: canvas::CanvasState, + pub services: Box, + worker: Option, + fonts_installed: bool, + /// Control-channel requests waiting for the next frame. + pub control_queue: Vec, +} + +impl FigApp { + pub fn new(session: Session, services: Box) -> Self { + Self { + session, + ui: UiState::default(), + canvas: canvas::CanvasState::default(), + services, + worker: None, + fonts_installed: false, + control_queue: Vec::new(), + } + } + + /// Run a command, surfacing errors as a toast. Returns the result. + pub fn run(&mut self, id: &str, params: Value) -> Option { + match self.session.execute(id, ¶ms) { + Ok(v) => { + self.handle_ui_requests(); + Some(v) + } + Err(e) => { + self.handle_ui_requests(); + let msg = e.to_string(); + log::warn!("{id}: {msg}"); + self.toast(msg.clone()); + self.ui.last_error = Some(msg); + None + } + } + } + + pub fn toast(&mut self, msg: impl Into) { + self.ui.toast = Some((msg.into(), 0.0)); + } + + /// Act on UI requests queued by commands (dialogs, panels). + pub fn handle_ui_requests(&mut self) { + let reqs = std::mem::take(&mut self.session.ui_requests); + for r in reqs { + match r.kind.as_str() { + "dialog.about" => self.ui.about = true, + "dialog.preferences" => self.ui.preferences = true, + "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, + "palette.actions" => self.ui.palette = Some(String::new()), + "panel.design" => self.ui.right_tab = RightTab::Design, + "panel.prototype" => self.ui.right_tab = RightTab::Prototype, + "panel.layers" => self.ui.left_tab = LeftTab::File, + "panel.assets" => self.ui.left_tab = LeftTab::Assets, + "home" => self.ui.home = true, + "dialog.saveAs" => self.save_as_dialog(), + "present" => { + if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { + self.ui.present = Some(present::Presenter::new(start)); + } + } + "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), + "layers.collapseAll" => self.ui.expanded.clear(), + "comment.new" => self.toast("Comments are local notes (coming soon)"), + "dialog.unsaved" => self.toast("This file has unsaved changes"), + _ => {} + } + } + if let Some(m) = self.session.message.take() { + self.toast(m); + } + } + + pub fn save_as_dialog(&mut self) { + let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) { + let ps = p.to_string_lossy().to_string(); + if self.run("file.saveAs", json!({"path": ps})).is_some() { + self.services.add_recent(&ps); + self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); + } + } + } + + pub fn save(&mut self) { + if self.session.ds().map(|d| d.path.is_some()).unwrap_or(false) { + if self.run("file.save", json!({})).is_some() { + self.toast("Saved"); + } + } else { + self.save_as_dialog(); + } + } + + pub fn open_dialog(&mut self) { + if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) { + let ps = p.to_string_lossy().to_string(); + if self.run("file.open", json!({"path": ps})).is_some() { + self.services.add_recent(&ps); + self.ui.home = false; + } + } + } + + pub fn import_dialog(&mut self, place: bool) { + let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif"] } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "figcraft"] }; + if let Some(p) = self.services.pick_open(if place { "Place image" } else { "Import" }, exts) { + let ps = p.to_string_lossy().to_string(); + self.run("file.import", json!({"path": ps})); + } + } + + /// Per-frame logic that doesn't draw (control requests, theme). + pub fn logic(&mut self, ctx: &egui::Context) { + if !self.fonts_installed { + theme::install_fonts(ctx); + self.fonts_installed = true; + } + let dark = match self.session.prefs.theme.as_str() { + "dark" => true, + "system" => ctx.global_style().visuals.dark_mode, + _ => false, + }; + self.ui.dark = dark; + theme::apply(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }); + if self.worker.is_none() { + self.worker = Some(render_worker::Worker::new(ctx.clone())); + } + } + + /// Draw the whole UI. + pub fn ui(&mut self, ui: &mut egui::Ui) { + #[cfg(not(target_arch = "wasm32"))] + let t0 = std::time::Instant::now(); + let ctx = ui.ctx().clone(); + let first = !self.fonts_installed; + self.logic(&ctx); + if first { + // New fonts apply from the next frame; draw nothing until then. + ctx.request_repaint(); + return; + } + let t = theme::Tokens::get(&ctx); + // Control requests run first so their results reflect this frame. + control::drain(self, &ctx); + if self.ui.present.is_some() { + present::show(self, ui); + return; + } + if self.session.docs.is_empty() || self.ui.home { + tabs::tab_bar(self, ui); + home::show(self, ui); + dialogs::show(self, &ctx); + return; + } + let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); + tabs::tab_bar(self, ui); + if !hidden { + egui::Panel::left("left_panel") + .resizable(true) + .default_size(self.ui.left_width) + .size_range(200.0..=480.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) + .show(ui, |ui| layers::left_panel(self, ui)); + egui::Panel::right("right_panel") + .resizable(true) + .default_size(self.ui.right_width) + .size_range(240.0..=480.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) + .show(ui, |ui| right::right_panel(self, ui)); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + let worker = self.worker.take(); + let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone())); + canvas::show(self, ui, &mut worker); + self.worker = Some(worker); + if !hidden { + toolbar::show(self, ui); + } + }); + shortcuts::handle(self, &ctx); + menus::popups(self, &ctx); + dialogs::show(self, &ctx); + self.toast_ui(&ctx); + #[cfg(not(target_arch = "wasm32"))] + { + self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0; + } + } + + fn toast_ui(&mut self, ctx: &egui::Context) { + let Some((msg, age)) = self.ui.toast.clone() else { return }; + let dt = ctx.input(|i| i.stable_dt) as f64; + let age = age + dt; + if age > 3.0 { + self.ui.toast = None; + return; + } + self.ui.toast = Some((msg.clone(), age)); + ctx.request_repaint(); + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::new("toast")).fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0)).pivot(egui::Align2::CENTER_BOTTOM).order(egui::Order::Tooltip).show(ctx, |ui| { + egui::Frame::NONE.fill(egui::Color32::from_rgb(0x1E, 0x1E, 0x1E)).corner_radius(egui::CornerRadius::same(8)).inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| { + ui.label(egui::RichText::new(msg).color(egui::Color32::WHITE).font(theme::regular(12.0))); + }); + }); + } + + /// UI state as JSON (control channel `ui.inspect`). + pub fn inspect(&self) -> Value { + json!({ + "rightTab": self.ui.right_tab, + "leftTab": self.ui.left_tab, + "home": self.ui.home, + "dark": self.ui.dark, + "presenting": self.ui.present.is_some(), + "mainMenu": self.ui.main_menu, + "palette": self.ui.palette.is_some(), + "frameMs": self.ui.frame_ms, + "renderMs": self.ui.render_ms, + "canvas": {"rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y]}, + "tool": self.session.tool_info(), + "status": self.session.status(), + "lastError": self.ui.last_error, + }) + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 00000000..5bed75a5 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,385 @@ +//! The main menu (from the engine's menu catalog), the canvas/layer context menu and the +//! Actions palette (⌘K / ⌘/). + +use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::catalog::{MENU, SEP}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular}; +use crate::toolbar::pretty_shortcut; + +/// Commands the UI layer implements itself (dialogs and platform actions). +pub const UI_COMMANDS: &[&str] = &["file.open", "file.import", "file.placeImage", "file.save", "file.saveAs", "file.export", "file.exportSvg", "edit.copyAsText", "edit.copyAsCss", "edit.copyAsSvg", "edit.copyAsPng", "edit.copyLink"]; + +/// Run a menu item: some open dialogs or use platform services, the rest go to the engine. +pub fn invoke(app: &mut FigApp, id: &str) { + match id { + "file.open" => app.open_dialog(), + "file.import" => app.import_dialog(false), + "file.placeImage" => app.import_dialog(true), + "file.save" => app.save(), + "file.saveAs" => app.save_as_dialog(), + "file.export" | "file.exportSvg" => app.ui.export_preview = true, + "edit.copyAsCss" => { + let ids = app.session.selection(); + if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { + let css = crate::right::css_for(doc, *first); + app.services.clipboard_set_text(&css); + app.toast("Copied as CSS"); + } + } + "edit.copyAsSvg" => { + let ids = app.session.selection(); + if let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) { + app.services.clipboard_set_text(&svg); + app.toast("Copied as SVG"); + } + } + "edit.copyAsPng" => { + let ids = app.session.selection(); + if let Some(first) = ids.first() + && let Some(pix) = app.session.doc().ok().and_then(|d| figcraft_render::render_node(d, *first, 2.0)) + { + let rgba = figcraft_render::to_rgba8(&pix); + app.services.clipboard_set_png(pix.width(), pix.height(), &rgba); + app.toast("Copied as PNG"); + } + } + "edit.copyAsText" => { + let doc = app.session.doc().ok(); + let text: Vec = app.session.selection().iter().filter_map(|i| doc.and_then(|d| d.node(*i)).and_then(|n| n.kind.text().map(|t| t.characters.clone()))).collect(); + app.services.clipboard_set_text(&text.join("\n")); + app.toast("Copied as text"); + } + "edit.copyLink" => { + let ids = app.session.selection(); + app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); + } + "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), + "edit.copy" | "edit.cut" => { + app.run(id, json!({})); + // Mirror to the system clipboard as SVG text so other apps can paste. + let ids: Vec<_> = app.session.clipboard.nodes.iter().map(|(r, _)| *r).collect(); + if id == "edit.copy" + && let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) + { + app.services.clipboard_set_text(&svg); + } + } + "edit.paste" => { + // Paste SVG / text from the system clipboard when our clipboard is empty. + if app.session.clipboard.nodes.is_empty() + && let Some(text) = app.services.clipboard_get_text() + { + if text.trim_start().starts_with(" { + if app.session.ds().map(|d| d.is_dirty()).unwrap_or(false) { + app.toast("Unsaved changes: save first (⌘S), or close the tab with ⌥-click to discard"); + } else { + app.run(id, json!({})); + } + } + _ => { + app.run(id, json!({})); + } + } +} + +fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { + let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; + String::from_utf8(bytes).ok() +} + +/// Whether a menu id is available (registered in the engine or handled by the UI). +pub fn available(id: &str) -> bool { + !id.is_empty() && (figcraft_engine::find_command(id).is_some() || UI_COMMANDS.contains(&id)) +} + +/// Draw one level of the main menu: `prefix` is the menu path ("File", "Edit/Copy as"). +fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + let mut subs: Vec = Vec::new(); + let mut last_sep = true; + for m in MENU.iter() { + if m.menu == prefix { + if m.label == SEP { + if !last_sep { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30))); + ui.add_space(4.0); + last_sep = true; + } + continue; + } + last_sep = false; + let enabled = available(m.id); + if item(ui, m.label, m.shortcut, enabled, false, &t) && enabled { + clicked = Some(m.id); + } + } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { + let sub = rest.split('/').next().unwrap_or(rest).to_string(); + if !subs.contains(&sub) { + subs.push(sub.clone()); + last_sep = false; + let full = format!("{prefix}/{sub}"); + let resp = submenu_item(ui, &sub, &t); + if resp.hovered() { + ui.data_mut(|d| d.insert_temp(Id::new(("submenu_open", prefix)), full.clone())); + } + let open: Option = ui.data(|d| d.get_temp(Id::new(("submenu_open", prefix)))); + if open.as_deref() == Some(full.as_str()) { + let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); + let mut child_clicked = false; + egui::Area::new(Id::new(("submenu", full.as_str()))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + menu_frame(ui, &t, |ui| { + child_clicked = menu_level(app, ui, &full); + }); + }); + if child_clicked { + return true; + } + } + } + } + } + if let Some(id) = clicked { + invoke(app, id); + return true; + } + false +} + +fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui)) { + egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(13)).inner_margin(egui::Margin::same(8)).shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(60) }).show(ui, |ui| { + ui.set_width(240.0); + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + add(ui); + }); +} + +fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + let hov = resp.hovered() && enabled; + if hov { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + let col = if enabled { t.menu_text } else { t.menu_dim }; + if checked { + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col); + } + ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), col); + if !shortcut.is_empty() { + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(shortcut), regular(12.0), if hov { t.menu_text } else { t.menu_dim }); + } + resp.clicked() +} + +fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.menu_text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(12.0, 12.0)), Icon::ChevronRight, t.menu_text); + resp +} + +/// Whether the pointer is over a menu (any foreground area). +fn over_menu(ctx: &egui::Context) -> bool { + ctx.input(|i| i.pointer.interact_pos()).and_then(|p| ctx.layer_id_at(p)).is_some_and(|l| l.order == egui::Order::Foreground) +} + +const TOP_MENUS: &[&str] = &["File", "Edit", "View", "Object", "Text", "Arrange", "Vector", "Plugins", "Preferences", "Libraries", "Help"]; + +pub fn popups(app: &mut FigApp, ctx: &egui::Context) { + let t = Tokens::get(ctx); + // Main menu. + if app.ui.main_menu { + let mut close = false; + let area = egui::Area::new(Id::new("main_menu")).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| { + menu_frame(ui, &t, |ui| { + // Quick actions search row. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim); + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, "Actions…", regular(12.0), t.menu_dim); + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut("Cmd+K"), regular(12.0), t.menu_dim); + if resp.clicked() { + app.ui.palette = Some(String::new()); + close = true; + } + ui.add_space(6.0); + for top in TOP_MENUS { + let resp = submenu_item(ui, top, &t); + if resp.hovered() { + ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); + } + let open: Option = ui.data(|d| d.get_temp(Id::new("main_open"))); + if open.as_deref() == Some(*top) { + let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0); + egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + menu_frame(ui, &t, |ui| { + if menu_level(app, ui, top) { + close = true; + } + }); + }); + } + } + }); + }); + let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if close || (clicked_outside && !area.response.hovered()) { + app.ui.main_menu = false; + } + } + // Context menu. + if let Some((pos, ids)) = app.ui.context_menu.clone() { + let mut close = false; + let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { + menu_frame(ui, &t, |ui| { + let has_sel = !ids.is_empty(); + let entries: &[(&str, &str)] = if has_sel { + &[ + ("Copy", "edit.copy"), + ("Paste here", "edit.paste"), + ("Paste to replace", "edit.pasteReplace"), + ("-", ""), + ("Bring to front", "arrange.front"), + ("Bring forward", "arrange.forward"), + ("Send backward", "arrange.backward"), + ("Send to back", "arrange.back"), + ("-", ""), + ("Group selection", "object.group"), + ("Frame selection", "object.frameSelection"), + ("Ungroup", "object.ungroup"), + ("Flatten", "vector.flatten"), + ("Outline stroke", "vector.outlineStroke"), + ("Use as mask", "object.mask"), + ("-", ""), + ("Add auto layout", "layout.add"), + ("Create component", "component.create"), + ("Detach instance", "component.detach"), + ("-", ""), + ("Show/Hide", "object.toggleVisible"), + ("Lock/Unlock", "object.toggleLock"), + ("-", ""), + ("Flip horizontal", "object.flipH"), + ("Flip vertical", "object.flipV"), + ("-", ""), + ("Copy as CSS", "edit.copyAsCss"), + ("Copy as SVG", "edit.copyAsSvg"), + ("Copy as PNG", "edit.copyAsPng"), + ("-", ""), + ("Delete", "edit.delete"), + ] + } else { + &[("Paste here", "edit.paste"), ("-", ""), ("Select all", "select.all"), ("-", ""), ("Show/Hide UI", "view.toggleUi"), ("Zoom to fit", "view.zoomFit"), ("Rulers", "view.rulers"), ("Pixel grid", "view.pixelGrid")] + }; + for (label, id) in entries { + if *label == "-" { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30))); + ui.add_space(4.0); + continue; + } + let sc = figcraft_engine::catalog::shortcut(id).unwrap_or(""); + if item(ui, label, sc, available(id), false, &t) { + if *id == "edit.paste" { + let p = app.to_page(pos); + if app.session.clipboard.nodes.is_empty() { + invoke(app, id); + } else { + app.run("edit.paste", json!({"x": p.x, "y": p.y})); + } + } else { + invoke(app, id); + } + close = true; + } + } + }); + }); + let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); + if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.context_menu = None; + } + } + crate::colorpicker::show(app, ctx); + palette(app, ctx); +} + +/// The Actions palette: fuzzy search over every command. +fn palette(app: &mut FigApp, ctx: &egui::Context) { + let Some(mut q) = app.ui.palette.clone() else { return }; + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let mut run: Option = None; + let mut close = false; + egui::Area::new(Id::new("palette")).fixed_pos(pos2(screen.center().x - 260.0, screen.min.y + 120.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE.fill(t.panel).corner_radius(CornerRadius::same(13)).stroke(Stroke::new(1.0, t.border)).shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(520.0); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search actions, layers and tools").desired_width(500.0).font(regular(14.0)).frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8)))); + r.request_focus(); + ui.add_space(4.0); + let ql = q.to_lowercase(); + let mut hits: Vec<(&str, &str, &str)> = figcraft_engine::all_commands() + .iter() + .filter(|c| ql.is_empty() || c.label.to_lowercase().contains(&ql) || c.id.to_lowercase().contains(&ql)) + .map(|c| (c.id, c.label, c.shortcut().unwrap_or(""))) + .collect(); + for m in MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)) { + if ql.is_empty() || m.label.to_lowercase().contains(&ql) { + hits.push((m.id, m.label, m.shortcut)); + } + } + hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); + hits.dedup_by(|a, b| a.0 == b.0); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (i, (id, label, sc)) in hits.iter().take(60).enumerate() { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if resp.hovered() || i == 0 { + ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover }); + } + let col = if resp.hovered() { Color32::WHITE } else { t.text }; + ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, *label, regular(12.0), col); + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(sc), regular(11.0), if resp.hovered() { Color32::WHITE } else { t.text_secondary }); + if resp.clicked() || (enter && i == 0) { + run = Some(id.to_string()); + } + } + }); + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + if let Some(id) = run { + app.ui.palette = None; + invoke(app, &id); + return; + } + app.ui.palette = if close { None } else { Some(q) }; + let _ = StrokeKind::Inside; +} diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs new file mode 100644 index 00000000..fb853c3a --- /dev/null +++ b/crates/ui-egui/src/present.rs @@ -0,0 +1,235 @@ +//! The prototype player: presents a frame full-window, follows interactions with transitions. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Ui, pos2, vec2}; +use figcraft_doc::proto::{Action, Transition, Trigger}; +use figcraft_doc::{NodeId, NodeKind}; + +use crate::FigApp; +use crate::theme::{regular, semibold}; + +pub struct Presenter { + pub current: NodeId, + pub history: Vec, + /// Transition in progress: (from, kind, started at, duration s). + pub anim: Option<(NodeId, Transition, f64, f64, figcraft_doc::proto::Easing)>, + pub overlay: Option, + texture: Option<(NodeId, egui::TextureHandle, f64)>, + from_texture: Option, + pub delay_started: f64, +} + +impl Presenter { + pub fn new(start: NodeId) -> Self { + Self { current: start, history: Vec::new(), anim: None, overlay: None, texture: None, from_texture: None, delay_started: -1.0 } + } +} + +fn frame_texture(app: &FigApp, ctx: &egui::Context, id: NodeId, scale: f64) -> Option { + let doc = app.session.doc().ok()?; + let pix = figcraft_render::render_node(doc, id, scale)?; + let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data()); + Some(ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR)) +} + +fn navigate(p: &mut Presenter, dest: NodeId, tr: Transition, dur: f64, easing: figcraft_doc::proto::Easing, now: f64) { + if dest == p.current { + return; + } + p.history.push(p.current); + p.anim = if matches!(tr, Transition::Instant) { None } else { Some((p.current, tr, now, dur.max(0.01), easing)) }; + p.from_texture = p.texture.take().map(|(_, t, _)| t); + p.current = dest; + p.delay_started = now; +} + +pub fn show(app: &mut FigApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let now = ctx.input(|i| i.time); + let Some(mut pres) = app.ui.present.take() else { return }; + let bg = app + .session + .ds() + .ok() + .and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) { + Some(NodeKind::Page { prototype, .. }) => Some(crate::theme::c32(prototype.background)), + _ => None, + }) + .unwrap_or(Color32::from_rgb(0x1E, 0x1E, 0x1E)); + let mut exit = false; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| { + let rect = ui.max_rect(); + let Ok(doc) = app.session.doc().map(|d| d.clone()) else { return }; + let Some(frame) = doc.node(pres.current).cloned() else { + exit = true; + return; + }; + // Fit the frame (max 1:1 like Figma's "fit" for small frames). + let avail = rect.shrink(48.0); + let scale = (f64::from(avail.width()) / frame.width.max(1.0)).min(f64::from(avail.height()) / frame.height.max(1.0)).min(1.0); + let ppp = f64::from(ctx.pixels_per_point()); + let need = pres.texture.as_ref().is_none_or(|(id, _, s)| *id != pres.current || (*s - scale * ppp).abs() > 1e-6); + if need { + if let Some(tex) = frame_texture(app, &ctx, pres.current, scale * ppp) { + pres.texture = Some((pres.current, tex, scale * ppp)); + } + } + let size = vec2((frame.width * scale) as f32, (frame.height * scale) as f32); + let target = Rect::from_center_size(rect.center(), size); + let mut t_anim = 1.0; + if let Some((_, tr, start, dur, easing)) = pres.anim.clone() { + let raw = ((now - start) / dur).clamp(0.0, 1.0); + t_anim = easing.eval(raw) as f32; + if raw >= 1.0 { + pres.anim = None; + pres.from_texture = None; + } else { + ctx.request_repaint(); + } + let _ = tr; + } + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let anim = pres.anim.as_ref().map(|a| a.1); + match (anim, &pres.from_texture, &pres.texture) { + (Some(tr), Some(from), Some((_, to, _))) => { + let w = target.width(); + match tr { + Transition::Dissolve | Transition::SmartAnimate => { + ui.painter().image(from.id(), target, uv, Color32::WHITE.gamma_multiply(1.0 - t_anim)); + ui.painter().image(to.id(), target, uv, Color32::WHITE.gamma_multiply(t_anim)); + } + Transition::Push { .. } | Transition::SlideIn { .. } | Transition::MoveIn { .. } => { + let dx = w * (1.0 - t_anim); + let painter = ui.painter_at(target); + if matches!(tr, Transition::Push { .. }) { + painter.image(from.id(), target.translate(vec2(dx - w, 0.0)), uv, Color32::WHITE); + } else { + painter.image(from.id(), target, uv, Color32::WHITE); + } + painter.image(to.id(), target.translate(vec2(dx, 0.0)), uv, Color32::WHITE); + } + _ => { + ui.painter().image(to.id(), target, uv, Color32::WHITE); + } + } + } + (_, _, Some((_, tex, _))) => { + ui.painter().image(tex.id(), target, uv, Color32::WHITE); + } + _ => {} + } + // Hotspots: clickable layers with interactions inside the frame. + let fw = doc.world_transform(pres.current); + let inv = fw.inverse(); + let mut hot: Vec<(NodeId, Rect)> = Vec::new(); + for id in doc.subtree(pres.current) { + let Some(n) = doc.node(id) else { continue }; + if n.interactions.is_empty() || !n.visible { + continue; + } + if let Some(b) = doc.world_bounds(id) { + let a = inv * figcraft_geom::Point::new(b.x0, b.y0); + let c = inv * figcraft_geom::Point::new(b.x1, b.y1); + let r = Rect::from_min_max( + pos2(target.min.x + (a.x * scale) as f32, target.min.y + (a.y * scale) as f32), + pos2(target.min.x + (c.x * scale) as f32, target.min.y + (c.y * scale) as f32), + ); + hot.push((id, r)); + } + } + let click = ui.interact(rect, ui.id().with("present_click"), Sense::click()); + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let mut fired = false; + for (id, r) in hot.iter().rev() { + let Some(n) = doc.node(*id) else { continue }; + let over = pointer.is_some_and(|p| r.contains(p)); + if over { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + } + for it in &n.interactions { + let trigger_hit = match it.trigger { + Trigger::OnClick | Trigger::MouseUp | Trigger::MouseDown | Trigger::OnPress => over && click.clicked(), + Trigger::OnHover | Trigger::MouseEnter => over, + Trigger::AfterDelay { ms } => *id == pres.current && pres.anim.is_none() && pres.delay_started >= 0.0 && now - pres.delay_started > f64::from(ms) / 1000.0, + _ => false, + }; + if !trigger_hit || fired { + continue; + } + for a in &it.actions { + match a { + Action::Navigate { dest } | Action::ChangeTo { dest } => { + let top = doc.top_level_frame(*dest).unwrap_or(*dest); + navigate(&mut pres, top, it.transition, f64::from(it.duration_ms) / 1000.0, it.easing, now); + fired = true; + } + Action::Back => { + if let Some(prev) = pres.history.pop() { + pres.from_texture = pres.texture.take().map(|(_, t, _)| t); + pres.anim = None; + pres.current = prev; + fired = true; + } + } + Action::OpenUrl { url } => { + app.services.open_url(url); + fired = true; + } + Action::OpenOverlay { dest, .. } | Action::SwapOverlay { dest } => { + pres.overlay = Some(*dest); + fired = true; + } + Action::CloseOverlay => { + pres.overlay = None; + fired = true; + } + _ => {} + } + } + } + } + if !fired && click.clicked() { + // Clicking empty space flashes hotspots (Figma behaviour): outline them briefly. + for (_, r) in &hot { + ui.painter().rect_stroke(*r, CornerRadius::same(2), egui::Stroke::new(2.0, crate::theme::ACCENT.gamma_multiply(0.6)), egui::StrokeKind::Outside); + } + } + // Overlay frame on top. + if let Some(ov) = pres.overlay + && let Some(on) = doc.node(ov) + && let Some(tex) = frame_texture(app, &ctx, ov, scale * ppp) + { + let osize = vec2((on.width * scale) as f32, (on.height * scale) as f32); + let orect = Rect::from_center_size(target.center(), osize); + ui.painter().rect_filled(target, CornerRadius::ZERO, Color32::from_black_alpha(80)); + ui.painter().image(tex.id(), orect, uv, Color32::WHITE); + if click.clicked() && pointer.is_some_and(|p| !orect.contains(p)) { + pres.overlay = None; + } + } + // Top bar. + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 40.0)); + ui.painter().rect_filled(bar, CornerRadius::ZERO, Color32::from_black_alpha(140)); + ui.painter().text(pos2(bar.min.x + 16.0, bar.center().y), Align2::LEFT_CENTER, &frame.name, semibold(12.0), Color32::WHITE); + ui.painter().text(pos2(bar.max.x - 16.0, bar.center().y), Align2::RIGHT_CENTER, "Esc to close · ← → for frames", regular(11.0), Color32::from_gray(200)); + if pres.delay_started < 0.0 { + pres.delay_started = now; + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + // Arrow keys step through top-level frames. + let frames = doc.top_frames(doc.page_of(pres.current).unwrap_or(pres.current)); + let idx = frames.iter().position(|f| *f == pres.current); + let (left, right) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowLeft), i.key_pressed(egui::Key::ArrowRight))); + if let Some(i) = idx { + if right && let Some(n) = frames.get(i + 1) { + navigate(&mut pres, *n, Transition::Instant, 0.0, Default::default(), now); + } + if left && i > 0 && let Some(n) = frames.get(i - 1) { + navigate(&mut pres, *n, Transition::Instant, 0.0, Default::default(), now); + } + } + }); + if exit || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + return; + } + app.ui.present = Some(pres); +} diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs new file mode 100644 index 00000000..645071c3 --- /dev/null +++ b/crates/ui-egui/src/render_worker.rs @@ -0,0 +1,114 @@ +//! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins; the UI +//! keeps showing the previous image, re-projected to the current view, until the new one arrives. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use figcraft_render::View; + +#[derive(Clone)] +pub struct Job { + pub doc: Arc, + pub page: NodeId, + pub view: View, + pub outline: bool, + pub skip: Vec, + pub generation: u64, +} + +pub struct Done { + pub rgba: Vec, + pub view: View, + pub generation: u64, + pub ms: f64, +} + +fn run(job: &Job) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let t0 = std::time::Instant::now(); + let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), ..Default::default() }; + let pix = figcraft_render::render_view(&job.doc, job.page, &job.view, &opts)?; + #[cfg(not(target_arch = "wasm32"))] + let ms = t0.elapsed().as_secs_f64() * 1000.0; + #[cfg(target_arch = "wasm32")] + let ms = 0.0; + Some(Done { rgba: pix.data().to_vec(), view: job.view.clone(), generation: job.generation, ms }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub struct Worker { + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, + pub in_flight: bool, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Worker { + pub fn new(ctx: egui::Context) -> Self { + let (tx, jobs) = std::sync::mpsc::channel::(); + let (done_tx, rx) = std::sync::mpsc::channel::(); + let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { + while let Ok(mut job) = jobs.recv() { + // Skip to the newest queued job. + while let Ok(newer) = jobs.try_recv() { + job = newer; + } + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job))); + if let Ok(Some(d)) = res + && done_tx.send(d).is_err() + { + break; + } + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::error!("render thread: {e}"); + } + Self { tx, rx, in_flight: false } + } + + pub fn submit(&mut self, job: Job) { + if self.tx.send(job).is_ok() { + self.in_flight = true; + } + } + + pub fn poll(&mut self) -> Option { + let mut last = None; + while let Ok(d) = self.rx.try_recv() { + last = Some(d); + } + if last.is_some() { + self.in_flight = false; + } + last + } +} + +#[cfg(target_arch = "wasm32")] +pub struct Worker { + pending: Option, + pub in_flight: bool, +} + +#[cfg(target_arch = "wasm32")] +impl Worker { + pub fn new(_ctx: egui::Context) -> Self { + Self { pending: None, in_flight: false } + } + pub fn submit(&mut self, job: Job) { + self.pending = Some(job); + self.in_flight = true; + } + pub fn poll(&mut self) -> Option { + let job = self.pending.take()?; + self.in_flight = false; + run(&job) + } +} + +/// Render synchronously (tests, screenshots). +pub fn render_now(job: &Job) -> Option { + run(job) +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs new file mode 100644 index 00000000..f6fcb963 --- /dev/null +++ b/crates/ui-egui/src/right.rs @@ -0,0 +1,1576 @@ +//! The right panel: Design, Prototype and Inspect tabs. + +use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_color::{BlendMode, Paint, PaintKind}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; +use crate::{FigApp, RightTab}; + +/// Common value of `f` across `ids` (None when mixed or empty). +fn common(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> T) -> Option { + let mut it = ids.iter().filter_map(|i| doc.node(*i)); + let first = f(it.next()?); + if it.all(|n| f(n) == first) { Some(first) } else { None } +} + +impl FigApp { + /// Set properties on `ids` (one undo step). + pub fn set_props(&mut self, ids: &[NodeId], props: Value) { + if ids.is_empty() { + return; + } + self.run("node.set", json!({"ids": ids, "props": props})); + } + + /// Apply a field edit: scrubs are live previews committed as one step. + pub fn field(&mut self, ids: &[NodeId], key: &str, edit: Option, map: impl Fn(f64) -> Value) { + match edit { + Some(FieldEdit::Set(v)) => self.set_props(ids, json!({ key: map(v) })), + Some(FieldEdit::Scrub(v, done)) => { + let _ = self.session.begin_interaction("Edit"); + let _ = self.session.execute("node.set", &json!({"ids": ids, "props": { key: map(v) }})); + if done { + let _ = self.session.commit_interaction(); + } + } + None => {} + } + } +} + +pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + // Top bar: avatar, present, share. + let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); + ui.painter().circle_filled(pos2(top.min.x + 24.0, top.center().y), 12.0, Color32::from_rgb(0x14, 0xAE, 0x5C)); + ui.painter().text(pos2(top.min.x + 24.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(11.0), Color32::WHITE); + let share = Rect::from_min_size(pos2(top.max.x - 72.0, top.center().y - 12.0), vec2(60.0, 24.0)); + let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).on_hover_text("Export and share"); + ui.painter().rect_filled(share, CornerRadius::same(5), if sresp.hovered() { t.accent_hover } else { ACCENT }); + ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.0), Color32::WHITE); + if sresp.clicked() { + app.ui.export_preview = true; + } + let play = Rect::from_min_size(pos2(share.min.x - 44.0, top.center().y - 12.0), vec2(36.0, 24.0)); + let presp = ui.interact(play, ui.id().with("play"), Sense::click()).on_hover_text("Present ⌥⌘↵"); + if presp.hovered() { + ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(play.center() - vec2(5.0, 0.0), vec2(12.0, 12.0)), Icon::Play, t.icon); + icons::paint(ui.painter(), Rect::from_center_size(play.center() + vec2(9.0, 0.0), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + if presp.clicked() { + app.run("proto.present", json!({})); + } + // Tabs row. + let (tabs, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border)); + let mut x = tabs.min.x + 8.0; + for (tab, label) in [(RightTab::Design, "Design"), (RightTab::Prototype, "Prototype")] { + let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); + let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click()); + let active = app.ui.right_tab == tab; + if active { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary }); + if resp.clicked() { + app.ui.right_tab = tab; + } + x = r.max.x + 2.0; + } + // Zoom menu. + let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); + let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0)); + let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click()); + if zresp.hovered() { + ui.painter().rect_filled(zr, CornerRadius::same(5), t.hover); + } + ui.painter().text(pos2(zr.min.x + 6.0, zr.center().y), Align2::LEFT_CENTER, format!("{}%", (zoom * 100.0).round()), regular(11.0), t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(zr.max.x - 9.0, zr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + egui::Popup::menu(&zresp).width(200.0).show(|ui| { + for (label, cmd) in [ + ("Zoom in", "view.zoomIn"), + ("Zoom out", "view.zoomOut"), + ("Zoom to fit", "view.zoomFit"), + ("Zoom to 50%", "view.zoom50"), + ("Zoom to 100%", "view.zoom100"), + ("Zoom to 200%", "view.zoom200"), + ("Zoom to selection", "view.zoomSelection"), + ] { + if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { + app.run(cmd, json!({})); + ui.close(); + } + } + ui.separator(); + for (label, cmd) in [("Pixel preview", "view.pixelPreview"), ("Pixel grid", "view.pixelGrid"), ("Snap to pixel grid", "view.snapPixel"), ("Layout grids", "view.layoutGrids"), ("Rulers", "view.rulers"), ("Outlines", "view.outlines")] { + if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) { + app.run(cmd, json!({})); + ui.close(); + } + } + }); + ui.painter().line_segment([tabs.left_bottom(), tabs.right_bottom()], Stroke::new(1.0, t.border)); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 0.0); + match app.ui.right_tab { + RightTab::Design => design(app, ui), + RightTab::Prototype => prototype(app, ui), + RightTab::Inspect => inspect(app, ui), + } + ui.add_space(80.0); + }); +} + +pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width().max(180.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + } + let col = if resp.hovered() { Color32::WHITE } else { t.text }; + ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.5), col); + if !shortcut.is_empty() { + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, crate::toolbar::pretty_shortcut(shortcut), regular(11.0), if resp.hovered() { Color32::WHITE } else { t.text_secondary }); + } + resp.clicked() +} + +fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) { + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, (h - FIELD_H) / 2.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.spacing_mut().item_spacing = vec2(8.0, 0.0); + add(&mut child); +} + +fn design(app: &mut FigApp, ui: &mut Ui) { + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + if sel.is_empty() { + page_section(app, ui, &doc); + return; + } + header(app, ui, &doc, &sel); + widgets::separator(ui); + position(app, ui, &doc, &sel); + widgets::separator(ui); + layout(app, ui, &doc, &sel); + widgets::separator(ui); + appearance(app, ui, &doc, &sel); + widgets::separator(ui); + if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) { + typography(app, ui, &doc, &sel); + widgets::separator(ui); + } + paints(app, ui, &doc, &sel, true); + widgets::separator(ui); + paints(app, ui, &doc, &sel, false); + widgets::separator(ui); + effects(app, ui, &doc, &sel); + widgets::separator(ui); + if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { + grids(app, ui, &doc, &sel); + widgets::separator(ui); + } + export(app, ui, &doc, &sel); +} + +fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { + let t = Tokens::get(ui.ctx()); + let Ok(page) = app.session.page() else { return }; + let bg = match doc.node(page).map(|n| &n.kind) { + Some(NodeKind::Page { background, .. }) => *background, + _ => figcraft_color::Color::DEFAULT_PAGE, + }; + widgets::section_header(ui, "Page", &[], false); + let mut open_picker = false; + let mut new_hex = None; + row(ui, 32.0, |ui| { + let p = Paint::solid(bg); + if widgets::swatch(ui, &p, 16.0).clicked() { + open_picker = true; + } + if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") { + new_hex = Some(h); + } + widgets::label(ui, "100 %", t.text); + }); + if open_picker { + app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground); + } + if let Some(h) = new_hex + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + app.run("page.setBackground", json!({"color": c.to_css_hex()})); + } + widgets::separator(ui); + // Local variables & styles. + let vars = doc.variables.variables.len(); + widgets::section_header(ui, "Local variables", &[], vars == 0); + row(ui, 32.0, |ui| { + let r = widgets::dropdown(ui, &format!("Open variables ({vars})"), 200.0, Some(Icon::Styles)); + if r.clicked() { + app.ui.preferences = false; + app.ui.palette = None; + ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true)); + } + }); + widgets::separator(ui); + let styles: Vec<(String, String, String)> = doc.styles.iter().map(|s| (s.id.clone(), s.name.clone(), s.kind.type_name().to_string())).collect(); + widgets::section_header(ui, "Local styles", &[], styles.is_empty()); + for (_, name, ty) in &styles { + row(ui, 28.0, |ui| { + let icon = match ty.as_str() { + "text" => Icon::Text, + "effect" => Icon::Effects, + "grid" => Icon::Grid, + _ => Icon::Fill, + }; + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon_secondary); + widgets::label(ui, name, t.text); + }); + } + widgets::separator(ui); + let all: Vec = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); + widgets::section_header(ui, "Export", &[(Icon::Plus, "Export page")], false); + row(ui, 32.0, |ui| { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside); + ui.painter().text(r.center(), Align2::CENTER_CENTER, "Export page as PNG", regular(11.0), t.text); + if resp.clicked() { + export_now(app, &all, "png", 1.0); + } + }); +} + +fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let label = match common(doc, sel, |n| n.kind.label()) { + Some(l) if sel.len() == 1 => l.to_string(), + Some(l) => format!("{l}s"), + None => "Mixed".into(), + }; + let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame)); + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); + let resp = ui.interact(lr, ui.id().with("typedd"), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); + } + ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(11.0), t.text); + if is_frame { + icons::paint(ui.painter(), Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + let ids: Vec = sel.to_vec(); + egui::Popup::menu(&resp).width(260.0).show(|ui| { + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + let mut cat = ""; + for (c, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS { + if *c != cat { + cat = c; + ui.label(egui::RichText::new(*c).font(semibold(11.0)).color(t.text_secondary)); + } + if menu_item(ui, name, &format!("{}×{}", pw, ph)) { + app.set_props(&ids, json!({"width": pw, "height": ph})); + ui.close(); + } + } + }); + }); + } + // Quick actions on the right. + let mut x = r.max.x - 8.0; + let actions: Vec<(Icon, &str, &str)> = vec![(Icon::Component, "Create component ⌥⌘K", "component.create"), (Icon::Mask, "Use as mask", "object.mask"), (Icon::Union, "Boolean groups", "bool.union")]; + for (icon, tip, cmd) in actions.into_iter().rev() { + let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); + let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).on_hover_text(tip); + if bresp.hovered() { + ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon); + if cmd == "bool.union" { + egui::Popup::menu(&bresp).width(180.0).show(|ui| { + for (l, c) in [("Union", "bool.union"), ("Subtract", "bool.subtract"), ("Intersect", "bool.intersect"), ("Exclude", "bool.exclude"), ("Flatten", "vector.flatten")] { + if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) { + app.run(c, json!({})); + ui.close(); + } + } + }); + } else if bresp.clicked() { + app.run(cmd, json!({})); + } + x -= 26.0; + } + // Instance row. + if sel.len() == 1 + && let Some(n) = sel.first().and_then(|i| doc.node(*i)) + && let NodeKind::Instance { component, props } = &n.kind + { + let comp_name = doc.node(*component).map(|c| c.name.clone()).unwrap_or_else(|| "Missing component".into()); + let set = doc.node(*component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|pn| matches!(pn.kind, NodeKind::ComponentSet { .. }))); + row(ui, 32.0, |ui| { + let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), ir, Icon::Instance, crate::theme::COMPONENT); + widgets::label(ui, &comp_name, t.text); + }); + // Variant properties. + if let Some(set) = set { + let mut opts: std::collections::BTreeMap> = Default::default(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(cn) = doc.node(c) { + for (k, v) in figcraft_engine::components::variant_props(&cn.name) { + let e = opts.entry(k).or_default(); + if !e.contains(&v) { + e.push(v); + } + } + } + } + let current = doc.node(*component).map(|c| figcraft_engine::components::variant_props(&c.name)).unwrap_or_default(); + for (k, vals) in opts { + let cur = current.get(&k).cloned().unwrap_or_default(); + let id = n.id; + row(ui, 32.0, |ui| { + widgets::label(ui, &k, t.text_secondary); + let resp = widgets::dropdown(ui, &cur, 120.0, None); + egui::Popup::menu(&resp).show(|ui| { + for v in &vals { + if menu_item(ui, v, "") { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): v}})); + ui.close(); + } + } + }); + }); + } + } + for (k, v) in props.clone() { + let id = n.id; + row(ui, 32.0, |ui| { + widgets::label(ui, &k, t.text_secondary); + match v { + Value::Bool(b) => { + if widgets::checkbox(ui, b, "") { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): !b}})); + } + } + Value::String(s) => { + if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &s, 120.0, "") { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}})); + } + } + _ => {} + } + }); + } + let id = n.id; + row(ui, 32.0, |ui| { + if icons::button(ui, Icon::Reset, 24.0, false, "Reset all changes").clicked() { + app.run("component.resetInstance", json!({"ids": [id]})); + } + if icons::button(ui, Icon::Detach, 24.0, false, "Detach instance ⌥⌘B").clicked() { + app.run("component.detach", json!({"ids": [id]})); + } + if icons::button(ui, Icon::Component, 24.0, false, "Go to main component").clicked() { + app.run("component.goToMain", json!({"id": id})); + } + if icons::button(ui, Icon::Export, 24.0, false, "Push changes to main component").clicked() { + app.run("component.pushChanges", json!({"id": id})); + } + }); + } +} + +fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + widgets::section_header(ui, "Position", &[], false); + let ids = sel.to_vec(); + row(ui, 32.0, |ui| { + let items = [ + (Icon::AlignLeft, "Align left ⌥A"), + (Icon::AlignHCenter, "Align horizontal centers ⌥H"), + (Icon::AlignRight, "Align right ⌥D"), + (Icon::AlignTop, "Align top ⌥W"), + (Icon::AlignVCenter, "Align vertical centers ⌥V"), + (Icon::AlignBottom, "Align bottom ⌥S"), + ]; + let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; + let w = ((ui.available_width() - 8.0) / 2.0 / 3.0).max(20.0); + if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { + app.run(cmds[i], json!({"ids": ids})); + } + if let Some(i) = widgets::segmented(ui, &items[3..], None, w) { + app.run(cmds[i + 3], json!({"ids": ids})); + } + }); + let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0); + let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0); + let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0); + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", half, 1.0); + app.field(&ids, "x", e, |v| json!(v)); + let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", half, 1.0); + app.field(&ids, "y", e, |v| json!(v)); + }); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::Rotate), rot, "°", half, 1.0); + app.field(&ids, "rotation", e, |v| json!(v)); + if icons::button(ui, Icon::Rotate, 24.0, false, "Rotate 90° right").clicked() { + app.run("object.rotateRight", json!({"ids": ids})); + } + if icons::button(ui, Icon::FlipH, 24.0, false, "Flip horizontal ⇧H").clicked() { + app.run("object.flipH", json!({"ids": ids})); + } + if icons::button(ui, Icon::FlipV, 24.0, false, "Flip vertical ⇧V").clicked() { + app.run("object.flipV", json!({"ids": ids})); + } + }); + // Constraints (children of non-auto-layout frames). + let in_frame = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && p.layout.is_none())); + if in_frame { + let c = common(doc, sel, |n| n.constraints); + row(ui, 32.0, |ui| { + let t = Tokens::get(ui.ctx()); + widgets::label(ui, "Constraints", t.text_secondary); + }); + row(ui, 32.0, |ui| { + let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, &pretty(&h), half, None); + egui::Popup::menu(&resp).show(|ui| { + for (l, v) in [("Left", "left"), ("Right", "right"), ("Left & right", "leftRight"), ("Center", "center"), ("Scale", "scale")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"horizontal": v}})); + ui.close(); + } + } + }); + let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, &pretty(&v), half, None); + egui::Popup::menu(&resp).show(|ui| { + for (l, val) in [("Top", "top"), ("Bottom", "bottom"), ("Top & bottom", "topBottom"), ("Center", "center"), ("Scale", "scale")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"vertical": val}})); + ui.close(); + } + } + }); + }); + } +} + +fn pretty(s: &str) -> String { + match s { + "LeftRight" => "Left & right".into(), + "TopBottom" => "Top & bottom".into(), + other => other.to_string(), + } +} + +fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let ids = sel.to_vec(); + let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let al = common(doc, sel, |n| n.layout.clone()); + let has_al = matches!(al, Some(Some(_))); + let title = if has_al { "Auto layout" } else { "Layout" }; + let btns: Vec<(Icon, &str)> = if frames && !has_al { vec![(Icon::Plus, "Add auto layout ⇧A")] } else if has_al { vec![(Icon::Minus, "Remove auto layout ⌥⇧A")] } else { vec![] }; + if let Some(_b) = widgets::section_header(ui, title, &btns, false) { + app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids})); + } + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + if let Some(Some(l)) = &al { + // Direction. + row(ui, 32.0, |ui| { + let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")]; + let active = match (l.mode, l.wrap) { + (LayoutMode::Vertical, _) => 0, + (LayoutMode::Horizontal, false) => 1, + (LayoutMode::Horizontal, true) => 2, + (LayoutMode::Grid, _) => 3, + }; + if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) { + let p = match i { + 0 => json!({"layout": "vertical", "wrap": false}), + 1 => json!({"layout": "horizontal", "wrap": false}), + 2 => json!({"layout": "horizontal", "wrap": true}), + _ => json!({"layout": "grid"}), + }; + app.set_props(&ids, p); + } + }); + } + // W / H with sizing. + let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); + let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", half, 1.0); + app.field(&ids, "width", e, |v| json!(v.max(0.0))); + let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", half, 1.0); + app.field(&ids, "height", e, |v| json!(v.max(0.0))); + }); + let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); + if has_al || parent_al || is_text { + let sh = common(doc, sel, |n| n.child_layout.sizing_h); + let sv = common(doc, sel, |n| n.child_layout.sizing_v); + row(ui, 32.0, |ui| { + for (axis, cur, key) in [("W", sh, "sizingH"), ("H", sv, "sizingV")] { + let label = cur.map(|s| s.label()).unwrap_or("Mixed"); + let resp = widgets::dropdown(ui, &format!("{label} {}", if axis == "W" { "width" } else { "height" }), half, Some(match cur { + Some(Sizing::Hug) => Icon::Hug, + Some(Sizing::Fill) => Icon::FillContainer, + _ => Icon::Fixed, + })); + let ids2 = ids.clone(); + egui::Popup::menu(&resp).show(|ui| { + let mut opts = vec![("Fixed", "fixed")]; + if has_al || is_text { + opts.push(("Hug contents", "hug")); + } + if parent_al { + opts.push(("Fill container", "fill")); + } + for (l, v) in opts { + if menu_item(ui, l, "") { + app.set_props(&ids2, json!({key: v})); + ui.close(); + } + } + }); + } + }); + } + if let Some(Some(l)) = &al { + // Alignment grid + gap. + row(ui, 72.0, |ui| { + let (grid, _) = ui.allocate_exact_size(vec2(72.0, 64.0), Sense::hover()); + ui.painter().rect_filled(grid, CornerRadius::same(5), t.field); + let horizontal = l.mode != LayoutMode::Vertical; + for yi in 0..3 { + for xi in 0..3 { + let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); + let r = Rect::from_center_size(c, vec2(22.0, 20.0)); + let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); + let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; + let cur_main = match l.main_align { + MainAlign::Min => 0, + MainAlign::Center => 1, + MainAlign::Max => 2, + MainAlign::SpaceBetween => 9, + }; + let cur_cross = match l.cross_align { + CrossAlign::Min | CrossAlign::Baseline => 0, + CrossAlign::Center => 1, + CrossAlign::Max => 2, + }; + let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i; + if on { + ui.painter().rect_filled(Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })), CornerRadius::same(1), ACCENT); + } else { + ui.painter().circle_filled(c, if resp.hovered() { 2.5 } else { 1.5 }, if resp.hovered() { ACCENT } else { t.text_tertiary }); + } + if resp.clicked() { + let m = ["min", "center", "max"][main_i]; + let cr = ["min", "center", "max"][cross_i]; + let main = if l.main_align == MainAlign::SpaceBetween { "spaceBetween" } else { m }; + app.set_props(&ids, json!({"mainAlign": main, "crossAlign": cr})); + } + } + } + ui.vertical(|ui| { + let auto = l.main_align == MainAlign::SpaceBetween; + let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0); + app.field(&ids, "gap", e, |v| json!(v)); + ui.add_space(8.0); + let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); + egui::Popup::menu(&resp).show(|ui| { + if menu_item(ui, "Fixed", "") { + app.set_props(&ids, json!({"mainAlign": "min"})); + ui.close(); + } + if menu_item(ui, "Auto (space between)", "") { + app.set_props(&ids, json!({"mainAlign": "spaceBetween"})); + ui.close(); + } + }); + }); + }); + // Padding. + let [pt, pr, pb, pl] = l.padding; + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), if pl == pr { Some(pl) } else { None }, "", half, 1.0); + app.field(&ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), if pt == pb { Some(pt) } else { None }, "", half, 1.0); + app.field(&ids, "paddingVertical", e, |v| json!(v.max(0.0))); + }); + if l.mode == LayoutMode::Grid { + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_cols"), Lead::Text("Cols"), Some(f64::from(l.grid_columns)), "", half, 1.0); + app.field(&ids, "gridColumns", e, |v| json!(v.max(1.0))); + let e = widgets::number_field(ui, Id::new("al_rows"), Lead::Text("Rows"), Some(f64::from(l.grid_rows)), "", half, 1.0); + app.field(&ids, "gridRows", e, |v| json!(v.max(1.0))); + }); + } + } + if frames { + let clip = common(doc, sel, |n| n.clip); + row(ui, 32.0, |ui| { + if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") { + app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)})); + } + }); + } + if parent_al { + let abs = common(doc, sel, |n| n.child_layout.absolute); + row(ui, 32.0, |ui| { + if widgets::checkbox(ui, abs.unwrap_or(false), "Ignore auto layout (absolute position)") { + app.set_props(&ids, json!({"absolute": !abs.unwrap_or(false)})); + } + }); + } +} + +fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let ids = sel.to_vec(); + let visible = common(doc, sel, |n| n.visible).unwrap_or(true); + if let Some(i) = widgets::section_header(ui, "Appearance", &[(Icon::Blend, "Blend mode"), (if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H")], false) + && i == 1 + { + app.run("object.toggleVisible", json!({"ids": ids})); + } + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round()); + let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); + let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", half, 1.0); + app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); + if radius_ok { + let e = widgets::number_field(ui, Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), "", half - 32.0, 1.0); + app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0))); + let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); + if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Independent corners").clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); + } + } + }); + if radius_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + let radii = common(doc, sel, |n| n.corners.radii); + let keys = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"]; + row(ui, 32.0, |ui| { + for (k, key) in keys.iter().enumerate().take(2) { + let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", half, 1.0); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + row(ui, 32.0, |ui| { + for (k, key) in keys.iter().enumerate().skip(2) { + let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", half, 1.0); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("smoothing"), Lead::Text("Smoothing"), sm, "%", half * 2.0 + 8.0, 1.0); + app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); + }); + } + let blend = common(doc, sel, |n| n.blend); + row(ui, 32.0, |ui| { + let label = blend.map(|b| b.label()).unwrap_or("Mixed"); + let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0, Some(Icon::Blend)); + egui::Popup::menu(&resp).width(200.0).show(|ui| { + for b in BlendMode::ALL { + if menu_item(ui, b.label(), "") { + app.set_props(&ids, json!({"blend": b.css()})); + ui.close(); + } + if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) { + ui.separator(); + } + } + }); + }); +} + +fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { + let t = Tokens::get(ui.ctx()); + let ids = sel.to_vec(); + let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() }); + let title = if fills { "Fill" } else { "Stroke" }; + let empty = list.as_ref().is_none_or(|l| l.is_empty()); + let style_name = common(doc, sel, |n| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten().and_then(|s| doc.style(&s).map(|st| st.name.clone())); + if let Some(i) = widgets::section_header(ui, title, &[(Icon::Styles, "Styles"), (Icon::Plus, if fills { "Add fill" } else { "Add stroke" })], empty) { + if i == 1 { + app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})); + } else { + ui.data_mut(|d| d.insert_temp(Id::new(("style_menu", fills)), true)); + } + } + style_picker(app, ui, doc, &ids, fills); + if let Some(name) = style_name { + row(ui, 32.0, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), r, Icon::Styles, t.icon_secondary); + widgets::label(ui, &name, t.text); + if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() { + app.run("style.detach", json!({"ids": ids, "which": if fills { "fill" } else { "stroke" }})); + } + }); + } + match &list { + None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)), + Some(l) => { + // Top of the list paints on top: show in reverse like Figma. + for (i, p) in l.iter().enumerate().rev() { + paint_row(app, ui, &ids, i, p, fills); + } + } + } + if !fills && !empty { + let weight = common(doc, sel, |n| n.stroke.weight); + let align = common(doc, sel, |n| n.stroke.align); + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, match align { + Some(StrokeAlign::Inside) => "Inside", + Some(StrokeAlign::Center) => "Center", + Some(StrokeAlign::Outside) => "Outside", + None => "Mixed", + }, half, None); + egui::Popup::menu(&resp).show(|ui| { + for (l, v) in [("Inside", "inside"), ("Center", "center"), ("Outside", "outside")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"strokeAlign": v})); + ui.close(); + } + } + }); + let e = widgets::number_field(ui, Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, "", half - 32.0, 1.0); + app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); + let more = icons::button(ui, Icon::Dots, 24.0, false, "Advanced stroke"); + egui::Popup::menu(&more).width(240.0).show(|ui| { + let st = sel.first().and_then(|i| doc.node(*i)).map(|n| n.stroke.clone()).unwrap_or_default(); + ui.label(egui::RichText::new("Stroke style").font(semibold(11.0))); + if menu_item(ui, "Solid", "") { + app.set_props(&ids, json!({"dashes": []})); + } + if menu_item(ui, "Dash", "") { + app.set_props(&ids, json!({"dashes": [st.weight.max(1.0) * 4.0, st.weight.max(1.0) * 4.0]})); + } + ui.separator(); + ui.label(egui::RichText::new("Join").font(semibold(11.0))); + for (l, v) in [("Miter", "miter"), ("Bevel", "bevel"), ("Round", "round")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"strokeJoin": v})); + } + } + ui.separator(); + ui.label(egui::RichText::new("Start / end point").font(semibold(11.0))); + for c in StrokeCap::ALL { + if menu_item(ui, &format!("Start: {}", c.label()), "") { + app.set_props(&ids, json!({"startCap": format!("{c:?}")})); + } + } + for c in StrokeCap::ALL { + if menu_item(ui, &format!("End: {}", c.label()), "") { + app.set_props(&ids, json!({"endCap": format!("{c:?}")})); + } + } + }); + }); + } +} + +fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) { + let t = Tokens::get(ui.ctx()); + let ids = ids.to_vec(); + let cmd = if fills { "fill.set" } else { "stroke.set" }; + row(ui, 32.0, |ui| { + let w = ui.available_width() - 56.0; + let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + widgets::paint_swatch(ui.painter(), sw, p); + let sresp = ui.interact(sw.expand(2.0), Id::new(("swatch", fills, i)), Sense::click()); + if sresp.clicked() { + app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }); + } + let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); + match &p.kind { + PaintKind::Solid { color } => { + if let Some(h) = widgets::text_field_inline(&mut child, Id::new(("hex", fills, i)), &color.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); + } + } + other => { + let label = match other { + PaintKind::Gradient(g) => format!("{:?}", g.kind), + _ => "Image".into(), + }; + child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text); + } + } + let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); + ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r)); + let e = widgets::number_field(&mut child, Id::new(("pop", fills, i)), Lead::None, Some((f64::from(p.opacity) * 100.0).round()), "%", 48.0, 1.0); + match e { + Some(FieldEdit::Set(v)) => { + app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)})); + } + Some(FieldEdit::Scrub(v, done)) => { + let _ = app.session.begin_interaction("Opacity"); + let _ = app.session.execute(cmd, &json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)})); + if done { + let _ = app.session.commit_interaction(); + } + } + None => {} + } + if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle visibility").clicked() { + app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible})); + } + if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { + app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})); + } + }); +} + +fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], fills: bool) { + let key = Id::new(("style_menu", fills)); + if !ui.data(|d| d.get_temp::(key)).unwrap_or(false) { + return; + } + let t = Tokens::get(ui.ctx()); + let styles: Vec<(String, String, Vec)> = doc + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Paint { paints } => Some((s.id.clone(), s.name.clone(), paints.clone())), + _ => None, + }) + .collect(); + let mut close = false; + egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(16, 8)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Color styles").font(semibold(11.0))); + if ui.small_button("Create style").clicked() { + let n = doc.styles.len() + 1; + app.run("style.create", json!({"type": if fills { "paint" } else { "stroke" }, "name": format!("Color {n}"), "from": ids.first()})); + close = true; + } + if ui.small_button("Close").clicked() { + close = true; + } + }); + for (id, name, paints) in &styles { + ui.horizontal(|ui| { + if let Some(p) = paints.first() { + widgets::swatch(ui, p, 14.0); + } + if ui.selectable_label(false, name).clicked() { + app.run("style.apply", json!({"ids": ids, "style": id, "target": if fills { "fill" } else { "stroke" }})); + close = true; + } + }); + } + }); + if close { + ui.data_mut(|d| d.remove::(key)); + } +} + +fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let ids = sel.to_vec(); + let list = common(doc, sel, |n| n.effects.clone()); + let empty = list.as_ref().is_none_or(|l| l.is_empty()); + if let Some(_i) = widgets::section_header(ui, "Effects", &[(Icon::Plus, "Add effect")], empty) { + app.run("effect.add", json!({"ids": ids, "type": "dropShadow"})); + } + let Some(list) = list else { return }; + for (i, e) in list.iter().enumerate().rev() { + let mut open = ui.data(|d| d.get_temp::(Id::new(("fx_open", i)))).unwrap_or(false); + row(ui, 32.0, |ui| { + if icons::button(ui, Icon::Effects, 24.0, open, "Effect settings").clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(Id::new(("fx_open", i)), open)); + } + let w = ui.available_width() - 56.0; + let resp = widgets::dropdown(ui, e.label(), w, None); + egui::Popup::menu(&resp).show(|ui| { + for (l, ty) in [("Drop shadow", "dropShadow"), ("Inner shadow", "innerShadow"), ("Layer blur", "layerBlur"), ("Background blur", "backgroundBlur"), ("Noise", "noise"), ("Texture", "texture")] { + if menu_item(ui, l, "") { + let ne = match ty { + "dropShadow" => Effect::drop_shadow(), + "innerShadow" => Effect::inner_shadow(), + "layerBlur" => Effect::layer_blur(), + "backgroundBlur" => Effect::background_blur(), + "noise" => Effect { kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) }, visible: true }, + _ => Effect { kind: EffectKind::Texture { size: 0.5, radius: 4.0 }, visible: true }, + }; + app.run("effect.set", json!({"ids": ids, "index": i, "effect": ne})); + ui.close(); + } + } + }); + if icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() { + let mut e2 = e.clone(); + e2.visible = !e2.visible; + app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2})); + } + if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { + app.run("effect.remove", json!({"ids": ids, "index": i})); + } + }); + if open { + effect_settings(app, ui, &ids, i, e); + } + } + let _ = t; +} + +fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + let set = |app: &mut FigApp, e2: Effect, scrub: Option| { + let p = json!({"ids": ids, "index": i, "effect": e2}); + match scrub { + None => { + app.run("effect.set", p); + } + Some(done) => { + let _ = app.session.begin_interaction("Effect"); + let _ = app.session.execute("effect.set", &p); + if done { + let _ = app.session.commit_interaction(); + } + } + } + }; + let edit = |app: &mut FigApp, ed: Option, f: &dyn Fn(f64) -> Effect| match ed { + Some(FieldEdit::Set(v)) => set(app, f(v), None), + Some(FieldEdit::Scrub(v, d)) => set(app, f(v), Some(d)), + None => {} + }; + match &e.kind { + EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => { + let inner = matches!(e.kind, EffectKind::InnerShadow(_)); + let mk = |s2: Shadow| Effect { kind: if inner { EffectKind::InnerShadow(s2) } else { EffectKind::DropShadow(s2) }, visible: e.visible }; + row(ui, 32.0, |ui| { + let ed = widgets::number_field(ui, Id::new(("fx_x", i)), Lead::Text("X"), Some(s.offset.0), "", half, 1.0); + edit(app, ed, &|v| mk(Shadow { offset: (v, s.offset.1), ..s.clone() })); + let ed = widgets::number_field(ui, Id::new(("fx_y", i)), Lead::Text("Y"), Some(s.offset.1), "", half, 1.0); + edit(app, ed, &|v| mk(Shadow { offset: (s.offset.0, v), ..s.clone() })); + }); + row(ui, 32.0, |ui| { + let ed = widgets::number_field(ui, Id::new(("fx_b", i)), Lead::Text("Blur"), Some(s.radius), "", half, 1.0); + edit(app, ed, &|v| mk(Shadow { radius: v.max(0.0), ..s.clone() })); + let ed = widgets::number_field(ui, Id::new(("fx_s", i)), Lead::Text("Spread"), Some(s.spread), "", half, 1.0); + edit(app, ed, &|v| mk(Shadow { spread: v, ..s.clone() })); + }); + row(ui, 32.0, |ui| { + let p = Paint::solid(s.color); + if widgets::swatch(ui, &p, 16.0).clicked() { + app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i }); + } + if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i)), &s.color.to_hex(), 80.0, "") + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + set(app, mk(Shadow { color: c.with_alpha(s.color.a), ..s.clone() }), None); + } + let ed = widgets::number_field(ui, Id::new(("fx_a", i)), Lead::None, Some((f64::from(s.color.a) * 100.0).round()), "%", 56.0, 1.0); + edit(app, ed, &|v| mk(Shadow { color: s.color.with_alpha((v / 100.0) as f32), ..s.clone() })); + }); + if !inner { + row(ui, 32.0, |ui| { + if widgets::checkbox(ui, s.show_behind, "Show behind transparent areas") { + set(app, mk(Shadow { show_behind: !s.show_behind, ..s.clone() }), None); + } + }); + } + } + EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => { + let bg = matches!(e.kind, EffectKind::BackgroundBlur { .. }); + row(ui, 32.0, |ui| { + let ed = widgets::number_field(ui, Id::new(("fx_r", i)), Lead::Text("Blur"), Some(*radius), "", half * 2.0 + 8.0, 1.0); + edit(app, ed, &|v| Effect { kind: if bg { EffectKind::BackgroundBlur { radius: v.max(0.0) } } else { EffectKind::LayerBlur { radius: v.max(0.0) } }, visible: e.visible }); + }); + } + EffectKind::Noise { size, density, color } => { + row(ui, 32.0, |ui| { + let ed = widgets::number_field(ui, Id::new(("fx_ns", i)), Lead::Text("Size"), Some(*size), "", half, 0.1); + edit(app, ed, &|v| Effect { kind: EffectKind::Noise { size: v.max(0.1), density: *density, color: *color }, visible: e.visible }); + let ed = widgets::number_field(ui, Id::new(("fx_nd", i)), Lead::Text("Density"), Some((density * 100.0).round()), "%", half, 1.0); + edit(app, ed, &|v| Effect { kind: EffectKind::Noise { size: *size, density: (v / 100.0).clamp(0.0, 1.0), color: *color }, visible: e.visible }); + }); + } + EffectKind::Texture { size, radius } => { + row(ui, 32.0, |ui| { + let ed = widgets::number_field(ui, Id::new(("fx_ts", i)), Lead::Text("Size"), Some(*size), "", half, 0.1); + edit(app, ed, &|v| Effect { kind: EffectKind::Texture { size: v.max(0.1), radius: *radius }, visible: e.visible }); + let ed = widgets::number_field(ui, Id::new(("fx_tr", i)), Lead::Text("Radius"), Some(*radius), "", half, 1.0); + edit(app, ed, &|v| Effect { kind: EffectKind::Texture { size: *size, radius: v.max(0.0) }, visible: e.visible }); + }); + } + } +} + +fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let ids = sel.to_vec(); + let list = common(doc, sel, |n| n.grids.clone()); + let empty = list.as_ref().is_none_or(|l| l.is_empty()); + if widgets::section_header(ui, "Layout grid", &[(Icon::Plus, "Add layout grid")], empty).is_some() { + app.run("grid.add", json!({"ids": ids})); + } + let Some(list) = list else { return }; + for (i, g) in list.iter().enumerate() { + row(ui, 32.0, |ui| { + let label = match g.kind { + GridKind::Grid => format!("Grid ({})", g.size), + GridKind::Columns => format!("{} columns", g.count), + GridKind::Rows => format!("{} rows", g.count), + }; + let w = ui.available_width() - 56.0; + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Grid)); + egui::Popup::menu(&resp).width(220.0).show(|ui| { + for (l, kind) in [("Grid", GridKind::Grid), ("Columns", GridKind::Columns), ("Rows", GridKind::Rows)] { + if menu_item(ui, l, "") { + let mut gs = list.clone(); + if let Some(slot) = gs.get_mut(i) { + *slot = match kind { + GridKind::Grid => LayoutGrid::default(), + GridKind::Columns => LayoutGrid::columns(), + GridKind::Rows => LayoutGrid::rows(), + }; + } + app.set_props(&ids, json!({"grids": gs})); + ui.close(); + } + } + }); + if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() { + let mut gs = list.clone(); + if let Some(slot) = gs.get_mut(i) { + slot.visible = !slot.visible; + } + app.set_props(&ids, json!({"grids": gs})); + } + if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { + app.run("grid.remove", json!({"ids": ids, "index": i})); + } + }); + } +} + +fn export_now(app: &mut FigApp, ids: &[NodeId], format: &str, scale: f64) { + let name = ids.first().and_then(|i| app.session.doc().ok()?.node(*i).map(|n| n.name.clone())).unwrap_or_else(|| "Export".into()); + let suffix = if (scale - 1.0).abs() > 1e-9 && format != "svg" && format != "pdf" { format!("@{scale}x") } else { String::new() }; + if let Some(p) = app.services.pick_save("Export", &format!("{name}{suffix}.{format}"), &[format]) { + let ps = p.to_string_lossy().to_string(); + if app.run("file.export", json!({"ids": ids, "format": format, "scale": scale, "path": ps})).is_some() { + app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); + } + } +} + +fn export(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let ids = sel.to_vec(); + let list = common(doc, sel, |n| n.exports.clone()).unwrap_or_default(); + if widgets::section_header(ui, "Export", &[(Icon::Plus, "Add export setting")], list.is_empty()).is_some() { + let next = match list.last().map(|e| e.size) { + Some(ExportSize::Scale { value }) => ExportSetting { size: ExportSize::Scale { value: value + 1.0 }, suffix: format!("@{}x", value + 1.0), format: ExportFormat::Png }, + _ => ExportSetting::default(), + }; + app.run("export.add", json!({"ids": ids, "setting": next})); + } + for (i, e) in list.iter().enumerate() { + row(ui, 32.0, |ui| { + let scale = match e.size { + ExportSize::Scale { value } => format!("{value}x"), + ExportSize::Width { value } => format!("{value}w"), + ExportSize::Height { value } => format!("{value}h"), + }; + let resp = widgets::dropdown(ui, &scale, 64.0, None); + egui::Popup::menu(&resp).show(|ui| { + for s in [0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] { + if menu_item(ui, &format!("{s}x"), "") { + let mut l = list.clone(); + if let Some(slot) = l.get_mut(i) { + slot.size = ExportSize::Scale { value: s }; + } + app.set_props(&ids, json!({"exports": l})); + ui.close(); + } + } + }); + if let Some(sfx) = widgets::text_field(ui, Id::new(("exp_sfx", i)), &e.suffix, 64.0, "Suffix") { + let mut l = list.clone(); + if let Some(slot) = l.get_mut(i) { + slot.suffix = sfx; + } + app.set_props(&ids, json!({"exports": l})); + } + let resp = widgets::dropdown(ui, &e.format.ext().to_uppercase(), 56.0, None); + egui::Popup::menu(&resp).show(|ui| { + for f in [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Webp] { + if menu_item(ui, &f.ext().to_uppercase(), "") { + let mut l = list.clone(); + if let Some(slot) = l.get_mut(i) { + slot.format = f; + } + app.set_props(&ids, json!({"exports": l})); + ui.close(); + } + } + }); + if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { + app.run("export.remove", json!({"ids": ids, "index": i})); + } + }); + } + let name = if sel.len() == 1 { sel.first().and_then(|i| doc.node(*i)).map(|n| n.name.clone()).unwrap_or_default() } else { format!("{} layers", sel.len()) }; + row(ui, 32.0, |ui| { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + let galley = ui.painter().layout(format!("Export {name}"), regular(11.0), t.text, w - 16.0); + ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text); + if resp.clicked() { + let e = list.first().cloned().unwrap_or_default(); + let scale = match e.size { + ExportSize::Scale { value } => value, + _ => 1.0, + }; + export_now(app, &ids, e.format.ext(), scale); + } + }); +} + +fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let t = Tokens::get(ui.ctx()); + let ids: Vec = sel.iter().copied().filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect(); + // While editing a range, style edits apply to it. + let range = figcraft_engine::textedit::selected_range(&app.session); + let style_of = |n: &Node| -> Option { + let td = n.kind.text()?; + match range { + Some((id, a, b)) if id == n.id && a != b => Some(td.style_at(a).clone()), + _ => Some(td.style.clone()), + } + }; + let st = common(doc, &ids, style_of).flatten(); + let td = common(doc, &ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v, t.auto_resize))).flatten(); + widgets::section_header(ui, "Typography", &[(Icon::Styles, "Text styles")], false); + let apply_style = |app: &mut FigApp, key: &str, v: Value| { + if let Some((id, a, b)) = range + && a != b + { + app.run("text.style", json!({"id": id, "start": a, "end": b, key: v})); + } else { + app.set_props(&ids, json!({ key: v })); + } + }; + let family = st.as_ref().map(|s| s.font_family.clone()).unwrap_or_else(|| "Mixed".into()); + let fstyle = st.as_ref().map(|s| s.font_style.clone()).unwrap_or_else(|| "Mixed".into()); + row(ui, 32.0, |ui| { + let w = ui.available_width(); + let resp = widgets::dropdown(ui, &family, w, None); + egui::Popup::menu(&resp).width(260.0).show(|ui| { + let q_id = Id::new("font_query"); + let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); + ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(240.0)); + ui.data_mut(|d| d.insert_temp(q_id, q.clone())); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for f in figcraft_text::family_names().into_iter().filter(|f| q.is_empty() || f.to_lowercase().contains(&q.to_lowercase())).take(400) { + if menu_item(ui, &f, "") { + apply_style(app, "fontFamily", json!(f)); + ui.close(); + } + } + }); + }); + }); + let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, &fstyle, half, None); + let fam = family.clone(); + egui::Popup::menu(&resp).width(200.0).show(|ui| { + for s in figcraft_text::family_styles(&fam) { + if menu_item(ui, &s, "") { + apply_style(app, "fontStyle", json!(s)); + ui.close(); + } + } + }); + let size = st.as_ref().map(|s| s.font_size); + let e = widgets::number_field(ui, Id::new("ty_size"), Lead::None, size, "", half, 1.0); + match e { + Some(FieldEdit::Set(v)) | Some(FieldEdit::Scrub(v, true)) => apply_style(app, "fontSize", json!(v.max(1.0))), + Some(FieldEdit::Scrub(v, false)) => { + let _ = app.session.begin_interaction("Font size"); + let _ = app.session.execute("node.set", &json!({"ids": ids, "props": {"fontSize": v.max(1.0)}})); + } + None => {} + } + if let Some(FieldEdit::Scrub(_, true)) = e { + let _ = app.session.commit_interaction(); + } + }); + row(ui, 32.0, |ui| { + let lh = st.as_ref().map(|s| s.line_height); + let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| "Mixed".into()); + if let Some(v) = widgets::text_field(ui, Id::new("ty_lh"), &lh_label, half, "Line height") { + apply_style(app, "lineHeight", if v.trim().eq_ignore_ascii_case("auto") { json!("auto") } else { json!(v.trim()) }); + } + let ls = st.as_ref().map(|s| s.letter_spacing.label()).unwrap_or_else(|| "Mixed".into()); + if let Some(v) = widgets::text_field(ui, Id::new("ty_ls"), &ls, half, "Letter spacing") { + apply_style(app, "letterSpacing", json!(v.trim())); + } + }); + row(ui, 32.0, |ui| { + let items = [(Icon::TextAlignLeft, "Align left"), (Icon::TextAlignCenter, "Align center"), (Icon::TextAlignRight, "Align right"), (Icon::TextAlignJustify, "Justify")]; + let active = td.map(|(h, _, _)| match h { + TextAlignH::Left => 0, + TextAlignH::Center => 1, + TextAlignH::Right => 2, + TextAlignH::Justified => 3, + }); + if let Some(i) = widgets::segmented(ui, &items, active, 26.0) { + app.set_props(&ids, json!({"textAlign": (["left", "center", "right", "justified"][i])})); + } + let items = [(Icon::TextTop, "Top"), (Icon::TextMiddle, "Middle"), (Icon::TextBottom, "Bottom")]; + let active = td.map(|(_, v, _)| match v { + TextAlignV::Top => 0, + TextAlignV::Middle => 1, + TextAlignV::Bottom => 2, + }); + if let Some(i) = widgets::segmented(ui, &items, active, 26.0) { + app.set_props(&ids, json!({"textAlignVertical": (["top", "middle", "bottom"][i])})); + } + }); + row(ui, 32.0, |ui| { + let items = [(Icon::Hug, "Auto width"), (Icon::LayoutV, "Auto height"), (Icon::Fixed, "Fixed size"), (Icon::Dots, "Truncate text")]; + let active = td.map(|(_, _, r)| match r { + TextAutoResize::WidthAndHeight => 0, + TextAutoResize::Height => 1, + TextAutoResize::None => 2, + TextAutoResize::Truncate => 3, + }); + if let Some(i) = widgets::segmented(ui, &items, active, 26.0) { + app.set_props(&ids, json!({"textAutoResize": (["widthAndHeight", "height", "none", "truncate"][i])})); + } + let deco = st.as_ref().map(|s| s.decoration); + let more = icons::button(ui, Icon::Dots, 24.0, false, "Type details"); + egui::Popup::menu(&more).width(220.0).show(|ui| { + ui.label(egui::RichText::new("Decoration").font(semibold(11.0))); + for (l, v) in [("None", "none"), ("Underline", "underline"), ("Strikethrough", "strikethrough")] { + if menu_item(ui, &format!("{}{l}", if format!("{deco:?}").eq_ignore_ascii_case(&format!("Some({l})")) { "✓ " } else { "" }), "") { + apply_style(app, "textDecoration", json!(v)); + } + } + ui.separator(); + ui.label(egui::RichText::new("Case").font(semibold(11.0))); + for (l, v) in [("Original", "original"), ("Uppercase", "upper"), ("Lowercase", "lower"), ("Title case", "title")] { + if menu_item(ui, l, "") { + apply_style(app, "textCase", json!(v)); + } + } + ui.separator(); + ui.label(egui::RichText::new("List").font(semibold(11.0))); + for (l, v) in [("None", "none"), ("Bulleted", "bulleted"), ("Numbered", "numbered")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"listStyle": v})); + } + } + }); + }); + let _ = t; +} + +fn prototype(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + let page = ds.page; + if sel.is_empty() { + let flows = match doc.node(page).map(|n| &n.kind) { + Some(NodeKind::Page { flows, .. }) => flows.clone(), + _ => vec![], + }; + widgets::section_header(ui, "Flow starting points", &[], flows.is_empty()); + for f in &flows { + row(ui, 32.0, |ui| { + if icons::button(ui, Icon::Play, 24.0, false, "Present this flow").clicked() { + app.run("proto.present", json!({"start": f.start})); + } + widgets::label(ui, &f.name, t.text); + }); + } + widgets::separator(ui); + widgets::section_header(ui, "Prototype settings", &[], false); + let (device, bg) = match doc.node(page).map(|n| &n.kind) { + Some(NodeKind::Page { prototype, .. }) => (prototype.device.clone(), prototype.background), + _ => ("None".into(), figcraft_color::Color::BLACK), + }; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, &device, ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for d in ["None", "iPhone 16", "iPhone 16 Pro Max", "Android Compact", "iPad mini", "MacBook Air", "Desktop"] { + if menu_item(ui, d, "") { + app.run("proto.settings", json!({"device": d})); + ui.close(); + } + } + }); + }); + row(ui, 32.0, |ui| { + widgets::swatch(ui, &Paint::solid(bg), 16.0); + if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + app.run("proto.settings", json!({"background": c.to_css_hex()})); + } + }); + return; + } + let ids = sel.clone(); + let first = sel.first().copied().unwrap_or(NodeId(0)); + let interactions = doc.node(first).map(|n| n.interactions.clone()).unwrap_or_default(); + if widgets::section_header(ui, "Interactions", &[(Icon::Plus, "Add interaction")], interactions.is_empty()).is_some() { + // Default: click → navigate to the next top-level frame. + let frames = doc.top_frames(page); + let own = doc.top_level_frame(first); + let dest = frames.iter().copied().find(|f| Some(*f) != own).or(frames.first().copied()); + match dest { + Some(d) => { + app.run("proto.interaction.add", json!({"ids": ids, "dest": d})); + } + None => app.toast("Add another frame to navigate to"), + } + } + for (i, it) in interactions.iter().enumerate() { + let dest_name = it.actions.first().and_then(|a| a.dest()).and_then(|d| doc.node(d)).map(|n| n.name.clone()); + row(ui, 40.0, |ui| { + ui.vertical(|ui| { + widgets::label(ui, &it.trigger.label(), t.text); + let action = it.actions.first().map(|a| a.label()).unwrap_or("None"); + widgets::label(ui, &format!("{action} {}", dest_name.clone().unwrap_or_default()), t.text_secondary); + }); + }); + row(ui, 32.0, |ui| { + let frames = doc.top_frames(page); + let resp = widgets::dropdown(ui, &dest_name.unwrap_or_else(|| "Choose destination".into()), ui.available_width() - 32.0, None); + let it2 = it.clone(); + egui::Popup::menu(&resp).show(|ui| { + for f in frames { + let name = doc.node(f).map(|n| n.name.clone()).unwrap_or_default(); + if menu_item(ui, &name, "") { + let mut ni = it2.clone(); + ni.actions = vec![proto::Action::Navigate { dest: f }]; + app.run("proto.interaction.set", json!({"ids": ids, "index": i, "interaction": ni})); + ui.close(); + } + } + }); + if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { + app.run("proto.interaction.remove", json!({"ids": ids, "index": i})); + } + }); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, it.transition.label(), ui.available_width(), None); + let it2 = it.clone(); + egui::Popup::menu(&resp).show(|ui| { + for (l, tr) in [ + ("Instant", proto::Transition::Instant), + ("Dissolve", proto::Transition::Dissolve), + ("Smart animate", proto::Transition::SmartAnimate), + ("Move in", proto::Transition::MoveIn { direction: proto::Direction::Left }), + ("Push", proto::Transition::Push { direction: proto::Direction::Left }), + ("Slide in", proto::Transition::SlideIn { direction: proto::Direction::Left }), + ] { + if menu_item(ui, l, "") { + let mut ni = it2.clone(); + ni.transition = tr; + app.run("proto.interaction.set", json!({"ids": ids, "index": i, "interaction": ni})); + ui.close(); + } + } + }); + }); + } + if doc.top_level_frame(first) == Some(first) { + widgets::separator(ui); + widgets::section_header(ui, "Flow starting point", &[(Icon::Plus, "Add starting point")], false); + row(ui, 32.0, |ui| { + if ui.button("Set as flow starting point").clicked() { + app.run("proto.flow.add", json!({"start": first})); + } + }); + widgets::separator(ui); + widgets::section_header(ui, "Scroll behavior", &[], false); + let ov = doc.node(first).map(|n| n.overflow).unwrap_or_default(); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, &format!("Overflow: {ov:?}"), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for (l, v) in [("No scrolling", "none"), ("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both directions", "both")] { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"overflow": v})); + ui.close(); + } + } + }); + }); + } +} + +/// CSS for a node (Inspect / Copy as CSS). +pub fn css_for(doc: &Document, id: NodeId) -> String { + let Some(n) = doc.node(id) else { return String::new() }; + let mut out = format!("/* {} */\n", n.name); + out.push_str(&format!("width: {}px;\nheight: {}px;\n", crate::canvas::fmt(n.width), crate::canvas::fmt(n.height))); + if let Some(l) = &n.layout { + out.push_str("display: flex;\n"); + out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" })); + if l.wrap { + out.push_str("flex-wrap: wrap;\n"); + } + out.push_str(&format!("gap: {}px;\n", crate::canvas::fmt(l.gap))); + let [pt, pr, pb, pl] = l.padding; + out.push_str(&format!("padding: {}px {}px {}px {}px;\n", crate::canvas::fmt(pt), crate::canvas::fmt(pr), crate::canvas::fmt(pb), crate::canvas::fmt(pl))); + out.push_str(&format!( + "justify-content: {};\nalign-items: {};\n", + match l.main_align { + MainAlign::Min => "flex-start", + MainAlign::Center => "center", + MainAlign::Max => "flex-end", + MainAlign::SpaceBetween => "space-between", + }, + match l.cross_align { + CrossAlign::Min => "flex-start", + CrossAlign::Center => "center", + CrossAlign::Max => "flex-end", + CrossAlign::Baseline => "baseline", + } + )); + } + if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) { + out.push_str(&format!("border-radius: {}px;\n", crate::canvas::fmt(r))); + } else if n.corners.radii.iter().any(|r| *r > 0.0) { + let [a, b, c, d] = n.corners.radii; + out.push_str(&format!("border-radius: {}px {}px {}px {}px;\n", a, b, c, d)); + } + if n.opacity < 1.0 { + out.push_str(&format!("opacity: {};\n", n.opacity)); + } + for p in n.fills.iter().filter(|p| p.visible) { + match &p.kind { + PaintKind::Solid { color } => { + let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" }; + out.push_str(&format!("{key}: {};\n", color.with_alpha(color.a * p.opacity).to_css())); + } + PaintKind::Gradient(g) => { + let stops: Vec = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect(); + out.push_str(&format!("background: linear-gradient({});\n", stops.join(", "))); + } + PaintKind::Image(_) => out.push_str("background: url(image.png) center / cover;\n"), + } + } + if n.has_visible_stroke() + && let Some(c) = n.strokes.first().and_then(|p| p.solid_color()) + { + out.push_str(&format!("border: {}px solid {};\n", crate::canvas::fmt(n.stroke.weight), c.to_css())); + } + let shadows: Vec = n + .effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| match &e.kind { + EffectKind::DropShadow(s) => Some(format!("{}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())), + EffectKind::InnerShadow(s) => Some(format!("inset {}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())), + _ => None, + }) + .collect(); + if !shadows.is_empty() { + out.push_str(&format!("box-shadow: {};\n", shadows.join(", "))); + } + for e in n.effects.iter().filter(|e| e.visible) { + match e.kind { + EffectKind::LayerBlur { radius } => out.push_str(&format!("filter: blur({}px);\n", radius / 2.0)), + EffectKind::BackgroundBlur { radius } => out.push_str(&format!("backdrop-filter: blur({}px);\n", radius / 2.0)), + _ => {} + } + } + if let NodeKind::Text(t) = &n.kind { + out.push_str(&format!("font-family: \"{}\";\nfont-size: {}px;\nfont-weight: {};\n", t.style.font_family, t.style.font_size, t.style.weight())); + if t.style.italic() { + out.push_str("font-style: italic;\n"); + } + match t.style.line_height { + LineHeight::Pixels(v) => out.push_str(&format!("line-height: {v}px;\n")), + LineHeight::Percent(p) => out.push_str(&format!("line-height: {p}%;\n")), + LineHeight::Auto => {} + } + if let LetterSpacing::Pixels(v) = t.style.letter_spacing + && v != 0.0 + { + out.push_str(&format!("letter-spacing: {v}px;\n")); + } + if let LetterSpacing::Percent(p) = t.style.letter_spacing + && p != 0.0 + { + out.push_str(&format!("letter-spacing: {}em;\n", p / 100.0)); + } + } + out +} + +fn inspect(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Ok(ds) = app.session.ds() else { return }; + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + widgets::section_header(ui, "Inspect", &[], false); + let Some(first) = sel.first().copied() else { + row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its code and measurements", t.text_secondary)); + return; + }; + if let Some(b) = doc.world_bounds(first) { + row(ui, 32.0, |ui| { + widgets::label(ui, &format!("{} × {} at {}, {}", crate::canvas::fmt(b.width()), crate::canvas::fmt(b.height()), crate::canvas::fmt(b.x0), crate::canvas::fmt(b.y0)), t.text); + }); + } + widgets::separator(ui); + widgets::section_header(ui, "CSS", &[(Icon::Export, "Copy")], false); + let css = css_for(&doc, first); + egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).outer_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| { + ui.label(egui::RichText::new(&css).monospace().color(t.text)); + }); + ui.add_space(8.0); + row(ui, 32.0, |ui| { + if ui.button("Copy CSS").clicked() { + app.services.clipboard_set_text(&css); + app.toast("Copied CSS"); + } + }); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs new file mode 100644 index 00000000..8426ee88 --- /dev/null +++ b/crates/ui-egui/src/shortcuts.rs @@ -0,0 +1,269 @@ +//! Keyboard handling: the catalog's shortcuts, tool keys, nudging, and text editing keys. + +use egui::{Context, Event, Key, Modifiers}; +use figcraft_engine::catalog::MENU; +use serde_json::json; + +use crate::FigApp; + +/// Parse "Shift+Cmd+K" into modifiers + key. +pub fn parse(s: &str) -> Option<(Modifiers, Key)> { + let mut m = Modifiers::NONE; + let mut key = None; + for part in s.split('+') { + match part { + "Shift" => m.shift = true, + "Cmd" => m.command = true, + "Alt" => m.alt = true, + "Ctrl" => m.ctrl = true, + "" => {} + k => key = key_of(k), + } + } + key.map(|k| (m, k)) +} + +fn key_of(k: &str) -> Option { + Some(match k { + "Delete" => Key::Backspace, + "Enter" => Key::Enter, + "Escape" => Key::Escape, + "Tab" => Key::Tab, + "PageUp" => Key::PageUp, + "PageDown" => Key::PageDown, + "=" => Key::Equals, + "-" => Key::Minus, + "[" => Key::OpenBracket, + "]" => Key::CloseBracket, + "\\" => Key::Backslash, + "'" => Key::Quote, + "," => Key::Comma, + "." => Key::Period, + "/" | "?" => Key::Slash, + other => Key::from_name(other)?, + }) +} + +fn mods_match(want: Modifiers, got: Modifiers) -> bool { + want.shift == got.shift && want.command == got.command && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) +} + +pub fn handle(app: &mut FigApp, ctx: &Context) { + // Text fields in panels own the keyboard. + if ctx.memory(|m| m.focused().is_some()) || app.ui.palette.is_some() { + return; + } + let events: Vec = ctx.input(|i| i.events.clone()); + let editing = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); + if editing { + text_keys(app, ctx, &events); + return; + } + for ev in &events { + match ev { + Event::Copy => crate::menus::invoke(app, "edit.copy"), + Event::Cut => crate::menus::invoke(app, "edit.cut"), + Event::Paste(text) => { + if app.session.clipboard.nodes.is_empty() && !text.is_empty() { + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + if text.trim_start().starts_with(" { + if key_event(app, *key, *modifiers, *repeat) { + // handled + } + } + _ => {} + } + } +} + +fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool { + // Catalog shortcuts (exact modifier match). + for item in MENU.iter().filter(|i| !i.shortcut.is_empty()) { + if let Some((want, k)) = parse(item.shortcut) + && k == key + && mods_match(want, m) + { + // Copy/cut/paste arrive as clipboard events on most platforms. + if matches!(item.id, "edit.copy" | "edit.cut" | "edit.paste") { + return true; + } + if repeat && !matches!(item.id, "edit.undo" | "edit.redo" | "view.zoomIn" | "view.zoomOut") { + return true; + } + if item.id == "select.none" { + if let Err(e) = app.session.escape() { + app.toast(e.to_string()); + } + app.ui.context_menu = None; + return true; + } + if item.id == "edit.delete" { + // Delete key also removes the pen path / ends gestures. + if app.session.tool.pen.is_some() { + let _ = app.session.pen_finish(); + return true; + } + } + if item.id == "select.children" && enter_text(app) { + return true; + } + crate::menus::invoke(app, item.id); + return true; + } + } + // Delete also via the Delete key. + if key == Key::Delete && m.is_none() { + app.run("edit.delete", json!({})); + return true; + } + // Arrow nudges. + let (dx, dy) = match key { + Key::ArrowLeft => (-1.0, 0.0), + Key::ArrowRight => (1.0, 0.0), + Key::ArrowUp => (0.0, -1.0), + Key::ArrowDown => (0.0, 1.0), + _ => (0.0, 0.0), + }; + if (dx, dy) != (0.0, 0.0) && !m.command && !m.alt { + if !app.session.selection().is_empty() { + app.run("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift})); + } + return true; + } + // Number keys: opacity. + if app.session.prefs.number_keys_opacity && m.is_none() && !app.session.selection().is_empty() { + let digit = match key { + Key::Num0 => Some(1.0), + Key::Num1 => Some(0.1), + Key::Num2 => Some(0.2), + Key::Num3 => Some(0.3), + Key::Num4 => Some(0.4), + Key::Num5 => Some(0.5), + Key::Num6 => Some(0.6), + Key::Num7 => Some(0.7), + Key::Num8 => Some(0.8), + Key::Num9 => Some(0.9), + _ => None, + }; + if let Some(o) = digit { + let ids = app.session.selection(); + app.set_props(&ids, json!({"opacity": o})); + return true; + } + } + // Enter while the pen is drawing finishes it. + if key == Key::Enter && app.session.tool.pen.is_some() { + let _ = app.session.pen_finish(); + return true; + } + false +} + +/// Enter on a single selected text layer starts editing it. +fn enter_text(app: &mut FigApp) -> bool { + let sel = app.session.selection(); + if sel.len() != 1 { + return false; + } + let Some(id) = sel.first().copied() else { return false }; + let is_text = app.session.doc().ok().and_then(|d| d.node(id)).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if is_text { + let _ = figcraft_engine::textedit::begin(&mut app.session, id, None, false); + return true; + } + false +} + +fn text_keys(app: &mut FigApp, _ctx: &Context, events: &[Event]) { + for ev in events { + match ev { + Event::Text(t) => { + if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { + app.toast(e.to_string()); + } + } + Event::Paste(t) => { + let _ = figcraft_engine::textedit::insert(&mut app.session, t); + } + Event::Copy | Event::Cut => { + if let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) + && let Some(text) = app.session.doc().ok().and_then(|d| d.node(id)).and_then(|n| n.kind.text()).map(|t| t.characters.chars().skip(a).take(b - a).collect::()) + { + app.services.clipboard_set_text(&text); + if matches!(ev, Event::Cut) { + let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); + } + } + } + Event::Key { key, pressed: true, modifiers: m, .. } => { + let name = match key { + Key::Backspace => "Backspace", + Key::Delete => "Delete", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Home => "Home", + Key::End => "End", + Key::Enter => "Enter", + Key::Escape => "Escape", + Key::Tab => "Tab", + Key::A if m.command => "SelectAll", + Key::B if m.command => { + app.run("text.bold", json!({})); + continue; + } + Key::I if m.command => { + app.run("text.italic", json!({})); + continue; + } + Key::U if m.command => { + app.run("text.underline", json!({})); + continue; + } + Key::Z if m.command => { + let _ = figcraft_engine::textedit::end(&mut app.session); + app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); + continue; + } + _ => continue, + }; + if name == "Enter" && m.command { + let _ = figcraft_engine::textedit::end(&mut app.session); + continue; + } + if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, m.alt, m.command) { + app.toast(e.to_string()); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_catalog_shortcuts() { + let (m, k) = parse("Shift+Cmd+Z").unwrap(); + assert!(m.shift && m.command && k == Key::Z); + assert_eq!(parse("Alt+Cmd+]").map(|x| x.1), Some(Key::CloseBracket)); + // Every catalog shortcut parses. + for item in MENU.iter().filter(|i| !i.shortcut.is_empty()) { + assert!(parse(item.shortcut).is_some(), "{}", item.shortcut); + } + } +} diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs new file mode 100644 index 00000000..48d0f81f --- /dev/null +++ b/crates/ui-egui/src/tabs.rs @@ -0,0 +1,110 @@ +//! The desktop tab strip: home, one tab per open file, new tab. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular}; + +pub const HEIGHT: f32 = 40.0; + +pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { + let rect = ui.max_rect(); + let mut x = rect.min.x + if cfg!(target_os = "macos") { 76.0 } else { 8.0 }; + // Home. + let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); + let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).on_hover_text("Home"); + let home_active = app.ui.home || app.session.docs.is_empty(); + if home_active { + ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active); + } else if hresp.hovered() { + ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); + if hresp.clicked() { + app.ui.home = true; + } + x += 44.0; + ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border)); + let mut switch = None; + let mut close = None; + for (i, d) in app.session.docs.iter().enumerate() { + let active = i == app.session.active && !app.ui.home; + let name = d.doc.name.clone(); + let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0).clamp(120.0, 220.0); + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); + let resp = ui.interact(r, ui.id().with(("tab", d.uid)), Sense::click()); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), Icon::Logo, crate::theme::ACCENT); + let label = if d.is_dirty() { format!("{name} •") } else { name }; + let galley = ui.painter().layout(label, regular(12.0), t.text, w - 60.0); + ui.painter().galley(pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, t.text); + let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); + if resp.hovered() || active { + let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + if cresp.clicked() { + close = Some(i); + } + } + if resp.clicked() && close.is_none() { + switch = Some(i); + } + if resp.middle_clicked() { + close = Some(i); + } + x += w; + ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); + } + // New tab. + let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); + let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).on_hover_text("New design file ⌘N"); + if nresp.hovered() { + ui.painter().rect_filled(nr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); + if nresp.clicked() { + app.run("file.new", json!({})); + app.ui.home = false; + } + // Right side: theme toggle. + let tr = Rect::from_center_size(pos2(rect.max.x - 24.0, rect.center().y), vec2(28.0, 28.0)); + let tresp = ui.interact(tr, ui.id().with("theme"), Sense::click()).on_hover_text("Toggle light/dark theme"); + if tresp.hovered() { + ui.painter().rect_filled(tr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), tr.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon); + if tresp.clicked() { + let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" }; + app.run(cmd, json!({})); + } + if let Some(i) = switch { + app.run("doc.switch", json!({"index": i})); + app.ui.home = false; + } + if let Some(i) = close { + let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty()); + if dirty { + app.ui.find = None; + app.toast("Unsaved changes: save first, or close again with ⌥ to discard"); + if ui.input(|inp| inp.modifiers.alt) { + app.run("file.close", json!({"index": i, "force": true})); + } + } else { + app.run("file.close", json!({"index": i})); + } + } + let _ = Color32::TRANSPARENT; + let _ = Align2::LEFT_TOP; + }); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 00000000..aa6a9fa3 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,231 @@ +//! Design tokens (UI3 light and dark) and fonts. + +use std::sync::Arc; + +use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub dark: bool, + pub bg: Color32, + pub panel: Color32, + pub canvas: Color32, + pub border: Color32, + pub border_strong: Color32, + pub text: Color32, + pub text_secondary: Color32, + pub text_tertiary: Color32, + pub icon: Color32, + pub icon_secondary: Color32, + pub hover: Color32, + pub pressed: Color32, + pub field: Color32, + pub field_hover_border: Color32, + pub accent: Color32, + pub accent_hover: Color32, + pub accent_text: Color32, + pub selected_row: Color32, + pub selected_row_child: Color32, + pub component: Color32, + pub component_row: Color32, + pub danger: Color32, + pub success: Color32, + pub warning: Color32, + pub guide: Color32, + pub tab_bar: Color32, + pub tab_active: Color32, + pub shadow: Color32, + pub menu: Color32, + pub menu_text: Color32, + pub menu_hover: Color32, + pub menu_dim: Color32, +} + +pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF); +pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); + +impl Tokens { + pub fn light() -> Self { + Self { + dark: false, + bg: Color32::from_rgb(0xFF, 0xFF, 0xFF), + panel: Color32::from_rgb(0xFF, 0xFF, 0xFF), + canvas: Color32::from_rgb(0xF5, 0xF5, 0xF5), + border: Color32::from_rgb(0xE6, 0xE6, 0xE6), + border_strong: Color32::from_rgb(0xD9, 0xD9, 0xD9), + text: Color32::from_rgb(0x1E, 0x1E, 0x1E), + text_secondary: Color32::from_rgb(0x75, 0x75, 0x75), + text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + icon: Color32::from_rgb(0x1E, 0x1E, 0x1E), + icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75), + hover: Color32::from_rgb(0xF5, 0xF5, 0xF5), + pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6), + field: Color32::from_rgb(0xF5, 0xF5, 0xF5), + field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), + accent: ACCENT, + accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5), + accent_text: Color32::from_rgb(0x00, 0x7B, 0xE5), + selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF), + selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF), + component: COMPONENT, + component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), + danger: Color32::from_rgb(0xF2, 0x48, 0x22), + success: Color32::from_rgb(0x14, 0xAE, 0x5C), + warning: Color32::from_rgb(0xFF, 0xCD, 0x29), + guide: Color32::from_rgb(0xF2, 0x48, 0x22), + tab_bar: Color32::from_rgb(0xE8, 0xE8, 0xE8), + tab_active: Color32::from_rgb(0xFF, 0xFF, 0xFF), + shadow: Color32::from_black_alpha(40), + menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), + menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), + menu_hover: ACCENT, + menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), + } + } + + pub fn dark() -> Self { + Self { + dark: true, + bg: Color32::from_rgb(0x2C, 0x2C, 0x2C), + panel: Color32::from_rgb(0x2C, 0x2C, 0x2C), + canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E), + border: Color32::from_rgb(0x44, 0x44, 0x44), + border_strong: Color32::from_rgb(0x55, 0x55, 0x55), + text: Color32::from_rgb(0xFF, 0xFF, 0xFF), + text_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + text_tertiary: Color32::from_rgb(0x75, 0x75, 0x75), + icon: Color32::from_rgb(0xF5, 0xF5, 0xF5), + icon_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + hover: Color32::from_rgb(0x38, 0x38, 0x38), + pressed: Color32::from_rgb(0x44, 0x44, 0x44), + field: Color32::from_rgb(0x38, 0x38, 0x38), + field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), + accent: ACCENT, + accent_hover: Color32::from_rgb(0x3D, 0xAD, 0xFF), + accent_text: Color32::from_rgb(0x7C, 0xC4, 0xF8), + selected_row: Color32::from_rgb(0x2A, 0x45, 0x5C), + selected_row_child: Color32::from_rgb(0x2D, 0x37, 0x41), + component: Color32::from_rgb(0xD2, 0x7C, 0xFF), + component_row: Color32::from_rgb(0x3E, 0x30, 0x52), + danger: Color32::from_rgb(0xF2, 0x48, 0x22), + success: Color32::from_rgb(0x14, 0xAE, 0x5C), + warning: Color32::from_rgb(0xFF, 0xCD, 0x29), + guide: Color32::from_rgb(0xF2, 0x48, 0x22), + tab_bar: Color32::from_rgb(0x1E, 0x1E, 0x1E), + tab_active: Color32::from_rgb(0x2C, 0x2C, 0x2C), + shadow: Color32::from_black_alpha(90), + menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), + menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), + menu_hover: ACCENT, + menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), + } + } + + pub fn get(ctx: &Context) -> Tokens { + ctx.data(|d| d.get_temp::(egui::Id::new("figcraft_tokens"))).unwrap_or_else(Tokens::light) + } +} + +/// Install tokens and egui visuals. +pub fn apply(ctx: &Context, t: Tokens) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("figcraft_tokens"), 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.field; + v.faint_bg_color = t.hover; + v.override_text_color = Some(t.text); + v.window_stroke = Stroke::new(1.0, t.border); + v.window_corner_radius = egui::CornerRadius::same(13); + v.menu_corner_radius = egui::CornerRadius::same(13); + v.selection.bg_fill = t.accent.gamma_multiply(0.3); + v.selection.stroke = Stroke::new(1.0, t.accent); + v.hyperlink_color = t.accent_text; + for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { + w.corner_radius = egui::CornerRadius::same(5); + w.fg_stroke = Stroke::new(1.0, t.text); + } + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border); + v.widgets.noninteractive.bg_fill = t.panel; + v.widgets.inactive.bg_fill = t.field; + v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + v.widgets.inactive.bg_stroke = Stroke::NONE; + v.widgets.hovered.bg_fill = t.hover; + v.widgets.hovered.weak_bg_fill = t.hover; + v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_hover_border); + v.widgets.active.bg_fill = t.pressed; + v.widgets.active.weak_bg_fill = t.pressed; + v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent); + v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; + v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow }; + v.text_cursor.stroke = Stroke::new(1.0, t.accent); + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(8.0, 6.0); + s.spacing.button_padding = egui::vec2(8.0, 4.0); + s.spacing.interact_size = egui::vec2(24.0, 24.0); + s.spacing.menu_margin = egui::Margin::same(8); + s.spacing.window_margin = egui::Margin::same(12); + s.text_styles = [ + (TextStyle::Small, FontId::new(10.0, FontFamily::Proportional)), + (TextStyle::Body, FontId::new(11.0, FontFamily::Proportional)), + (TextStyle::Button, FontId::new(11.0, FontFamily::Proportional)), + (TextStyle::Heading, FontId::new(13.0, FontFamily::Name("semibold".into()))), + (TextStyle::Monospace, FontId::new(11.0, FontFamily::Monospace)), + ] + .into(); + s.interaction.tooltip_delay = 0.4; + }); +} + +/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults. +pub fn install_fonts(ctx: &Context) { + let mut fonts = FontDefinitions::default(); + let find = |family: &str, style: &str| figcraft_text::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == style).map(|f| f.bytes); + let regular = find("Inter", "Regular"); + let semibold = find("Inter", "SemiBold").or_else(|| find("Inter", "Bold")); + if let Some(r) = regular { + fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(r))); + if let Some(list) = fonts.families.get_mut(&FontFamily::Proportional) { + list.insert(0, "inter".into()); + } + } + let sb_name = if let Some(sb) = semibold { + fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(sb))); + "inter-semibold" + } else { + "inter" + }; + let mut semi: Vec = Vec::new(); + if fonts.font_data.contains_key(sb_name) { + semi.push(sb_name.into()); + } + semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); + fonts.families.insert(FontFamily::Name("semibold".into()), semi); + // CJK and other scripts from craft-fonts as fallbacks. + for f in figcraft_text::CRAFT_FONTS.iter().filter(|f| f.family != "Inter" && f.style == "Regular") { + if matches!(f.family, "BIZ UDPGothic" | "Noto Sans Arabic") { + let key = format!("craft-{}", f.family); + fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(f.bytes))); + for fam in [FontFamily::Proportional, FontFamily::Name("semibold".into())] { + if let Some(list) = fonts.families.get_mut(&fam) { + list.push(key.clone()); + } + } + } + } + ctx.set_fonts(fonts); +} + +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("semibold".into())) +} + +pub fn regular(size: f32) -> FontId { + FontId::new(size, FontFamily::Proportional) +} + +pub fn c32(c: figcraft_color::Color) -> Color32 { + let [r, g, b, a] = c.to_rgba8(); + Color32::from_rgba_unmultiplied(r, g, b, a) +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs new file mode 100644 index 00000000..4cbe0345 --- /dev/null +++ b/crates/ui-egui/src/toolbar.rs @@ -0,0 +1,180 @@ +//! The floating bottom toolbar (UI3): tool groups with flyouts, comments, actions. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::ToolId; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular}; + +struct Group { + key: &'static str, + tools: &'static [(ToolId, Icon)], +} + +const GROUPS: &[Group] = &[ + Group { key: "move", tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)] }, + Group { key: "frame", tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)] }, + Group { + key: "shape", + tools: &[ + (ToolId::Rectangle, Icon::Rectangle), + (ToolId::Line, Icon::Line), + (ToolId::Arrow, Icon::Arrow), + (ToolId::Ellipse, Icon::Ellipse), + (ToolId::Polygon, Icon::Polygon), + (ToolId::Star, Icon::Star), + ], + }, + Group { key: "pen", tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil)] }, + Group { key: "text", tools: &[(ToolId::Text, Icon::Text)] }, + Group { key: "comment", tools: &[(ToolId::Comment, Icon::Comment)] }, +]; + +fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { + ui.data(|d| d.get_temp::(egui::Id::new(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default) +} + +pub fn show(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let canvas = app.canvas.rect; + let cell = 32.0; + let gap = 4.0; + let n_cells = GROUPS.len() as f32 + 1.0; + let width = n_cells * (cell + 10.0) + 16.0 + 64.0; + let height = 48.0; + let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - height / 2.0), vec2(width, height)); + let painter = ui.painter(); + // Shadow + pill. + for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] { + painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a)); + } + painter.rect_filled(rect, CornerRadius::same(13), t.panel); + painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside); + let current = app.session.tool.tool; + let mut x = rect.min.x + 8.0; + let cy = rect.center().y; + for g in GROUPS { + let in_group = g.tools.iter().any(|(tid, _)| *tid == current); + let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; + let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); + let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with(("tool", g.key)), Sense::click()); + let active = in_group; + let bg = if active { ACCENT } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }; + ui.painter().rect_filled(r, CornerRadius::same(5), bg); + icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + let resp = resp.on_hover_text(format!("{} {}", shown.label(), shown.shortcut())); + if resp.clicked() { + app.run("tool.set", json!({"tool": shown.id()})); + } + x += cell; + // Flyout chevron. + if g.tools.len() > 1 { + let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell)); + let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click()); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + if cresp.clicked() { + app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; + } + x += 10.0; + } + x += gap; + if app.ui.tool_flyout == Some(g.key) { + flyout(app, ui, g, pos2(r.min.x, rect.min.y - 8.0)); + } + } + // Separator and Actions. + ui.painter().line_segment([pos2(x + 2.0, cy - 12.0), pos2(x + 2.0, cy + 12.0)], Stroke::new(1.0, t.border)); + x += 8.0; + let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); + let resp = ui.interact(r, ui.id().with("actions"), Sense::click()).on_hover_text("Actions ⌘K"); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), r.shrink(7.0), Icon::Actions, t.icon); + if resp.clicked() { + app.ui.palette = Some(String::new()); + } + x += cell + 6.0; + // Dev mode toggle look-alike: the Code (inspect) panel. + let r = Rect::from_min_size(pos2(x, cy - 14.0), vec2(56.0, 28.0)); + let resp = ui.interact(r, ui.id().with("devmode"), Sense::click()).on_hover_text("Inspect: code and measurements"); + let on = app.ui.right_tab == crate::RightTab::Inspect; + ui.painter().rect_filled(r, CornerRadius::same(14), if on { Color32::from_rgb(0x0F, 0xA9, 0x58) } else { t.field }); + let knob = if on { pos2(r.max.x - 14.0, cy) } else { pos2(r.min.x + 14.0, cy) }; + ui.painter().circle_filled(knob, 11.0, Color32::WHITE); + icons::paint(ui.painter(), Rect::from_center_size(knob, vec2(14.0, 14.0)), Icon::Code, if on { Color32::from_rgb(0x0F, 0xA9, 0x58) } else { t.icon }); + if resp.clicked() { + app.ui.right_tab = if on { crate::RightTab::Design } else { crate::RightTab::Inspect }; + } +} + +fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { + let t = Tokens::get(ui.ctx()); + let item_h = 32.0; + let w = 220.0; + let h = item_h * g.tools.len() as f32 + 16.0; + let mut chosen = None; + egui::Area::new(egui::Id::new(("flyout_area", g.key))).fixed_pos(pos2(bottom_left.x, bottom_left.y - h)).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(13), t.menu); + let mut y = rect.min.y + 8.0; + for (tid, icon) in g.tools { + let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(w - 16.0, item_h)); + let resp = ui.interact(r, ui.id().with(("fly", tid.id())), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); + } + if *tid == app.session.tool.tool { + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.menu_text); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(16.0, 16.0)), *icon, t.menu_text); + ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tid.label(), regular(12.0), t.menu_text); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(tid.shortcut()), regular(12.0), t.menu_dim); + if resp.clicked() { + chosen = Some(*tid); + } + y += item_h; + } + }); + if let Some(tid) = chosen { + if let Some(i) = ToolId::ALL.iter().position(|x| *x == tid) { + ui.data_mut(|d| d.insert_temp(egui::Id::new(("tb_last", g.key)), i as u8)); + } + app.run("tool.set", json!({"tool": tid.id()})); + app.ui.tool_flyout = None; + } +} + +/// "Shift+Cmd+K" → "⇧⌘K" on macOS. +pub fn pretty_shortcut(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + let mac = cfg!(target_os = "macos"); + if !mac { + return s.replace("Cmd", "Ctrl").replace("Alt", "Alt"); + } + let mut out = String::new(); + for part in s.split('+') { + out.push_str(match part { + "Shift" => "⇧", + "Cmd" => "⌘", + "Alt" => "⌥", + "Ctrl" => "⌃", + "Delete" => "⌫", + "Enter" => "↵", + "Escape" => "Esc", + "Tab" => "⇥", + "PageUp" => "⇞", + "PageDown" => "⇟", + other => other, + }); + } + out +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 00000000..1543cd22 --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,438 @@ +//! Figma-style compact controls: number fields with scrub labels, text fields, dropdowns, +//! segmented buttons, colour swatches and section headers. + +use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; + +pub const ROW_H: f32 = 32.0; +pub const FIELD_H: f32 = 24.0; +pub const PAD: f32 = 16.0; + +/// What a numeric field's label shows: text ("X") or an icon. +#[derive(Clone, Copy)] +pub enum Lead<'a> { + Text(&'a str), + Icon(Icon), + None, +} + +/// Result of a field edit. +pub enum FieldEdit { + /// Committed a new value (Enter / focus loss). + Set(f64), + /// Scrubbed by dragging the label (live; `.1` is true when the drag ended). + Scrub(f64, bool), +} + +fn fmt_num(v: f64) -> String { + if !v.is_finite() { + return String::new(); + } + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } +} + +/// A number field `width` wide. `value` None shows "Mixed". `suffix` (e.g. "%" or "°") is shown +/// after the number. Typing supports simple math (`10+5`, `*2`). +pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + let lead_w = match lead { + Lead::None => 6.0, + Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0), + Lead::Icon(_) => 24.0, + }; + let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H)); + let edit_rect = Rect::from_min_max(pos2(rect.min.x + lead_w, rect.min.y), rect.max); + let editing_id = id.with("editing"); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); + let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::click_and_drag()); + let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some(); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); + if editing.is_some() { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if hovered { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + match lead { + Lead::Text(s) => { + ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary); + } + Lead::Icon(i) => icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary), + Lead::None => {} + } + if lead_resp.hovered() || lead_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let mut out = None; + // Scrub by dragging the label. + if let Some(v) = value { + if lead_resp.drag_started() { + ui.data_mut(|d| d.insert_temp(id.with("scrub0"), v)); + } + if lead_resp.dragged() || lead_resp.drag_stopped() { + let v0: f64 = ui.data(|d| d.get_temp(id.with("scrub0"))).unwrap_or(v); + let total = lead_resp.total_drag_delta().map(|d| d.x).unwrap_or(0.0) as f64; + let shift = ui.input(|i| i.modifiers.shift); + let k = if shift { step * 10.0 } else { step }; + let nv = v0 + (total * k).round(); + out = Some(FieldEdit::Scrub(nv, lead_resp.drag_stopped())); + } + } + if editing.is_none() && field_resp.clicked() { + editing = Some(value.map(fmt_num).unwrap_or_default()); + ui.data_mut(|d| d.insert_temp(editing_id, editing.clone().unwrap_or_default())); + ui.memory_mut(|m| m.request_focus(id.with("te"))); + ui.data_mut(|d| d.insert_temp(id.with("selectall"), true)); + } + match editing { + Some(mut text) => { + let te = egui::TextEdit::singleline(&mut text) + .id(id.with("te")) + .frame(egui::Frame::NONE) + .font(regular(11.0)) + .desired_width(edit_rect.width() - 6.0) + .vertical_align(egui::Align::Center); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect.shrink2(vec2(0.0, 4.0)))); + let r = child.add(te); + if ui.data(|d| d.get_temp::(id.with("selectall"))).unwrap_or(false) + && let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id.with("te")) + { + let ccursor = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(ccursor)); + state.store(ui.ctx(), id.with("te")); + ui.data_mut(|d| d.remove::(id.with("selectall"))); + } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let arrow = ui.input(|i| { + if i.key_pressed(egui::Key::ArrowUp) { + Some(1.0) + } else if i.key_pressed(egui::Key::ArrowDown) { + Some(-1.0) + } else { + None + } + }); + if r.has_focus() + && let Some(dir) = arrow + && let Some(v) = eval(&text, value) + { + let shift = ui.input(|i| i.modifiers.shift); + let nv = v + dir * if shift { 10.0 } else { step }; + text = fmt_num(nv); + out = Some(FieldEdit::Set(nv)); + } + if esc { + ui.data_mut(|d| d.remove::(editing_id)); + ui.memory_mut(|m| m.surrender_focus(id.with("te"))); + } else if r.lost_focus() || enter { + if let Some(v) = eval(&text, value) { + out = Some(FieldEdit::Set(v)); + } + ui.data_mut(|d| d.remove::(editing_id)); + } else { + ui.data_mut(|d| d.insert_temp(editing_id, text)); + } + let _ = suffix; + } + None => { + let s = match value { + Some(v) => format!("{}{}", fmt_num(v), suffix), + None => "Mixed".into(), + }; + let col = if value.is_some() { t.text } else { t.text_secondary }; + ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); + } + } + out +} + +/// Evaluate a typed number: plain, or simple arithmetic relative to `cur` ("+10", "*2", "50%"). +pub fn eval(s: &str, cur: Option) -> Option { + let s = s.trim().trim_end_matches(['%', '°']).trim().replace(',', "."); + if s.is_empty() { + return None; + } + if let Ok(v) = s.parse::() { + return v.is_finite().then_some(v); + } + // a OP b, or OP b relative to cur. + for op in ['+', '-', '*', '/'] { + if let Some(idx) = s.get(1..).and_then(|r| r.find(op)).map(|i| i + 1).or_else(|| s.starts_with(op).then_some(0)) { + let (a, b) = (s.get(..idx).unwrap_or("").trim(), s.get(idx + 1..).unwrap_or("").trim()); + let a = if a.is_empty() { cur? } else { a.parse::().ok()? }; + let b = b.parse::().ok()?; + let v = match op { + '+' => a + b, + '-' => a - b, + '*' => a * b, + _ => { + if b == 0.0 { + return None; + } + a / b + } + }; + return v.is_finite().then_some(v); + } + } + None +} + +/// A text field that commits on Enter / focus loss. Returns the new text when committed. +pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + let editing_id = id.with("editing"); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + let resp = ui.interact(rect, id.with("click"), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); + if editing.is_some() { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + if editing.is_none() && resp.clicked() { + editing = Some(value.to_string()); + ui.memory_mut(|m| m.request_focus(id.with("te"))); + } + let mut out = None; + match editing { + Some(mut text) => { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); + let r = child.add(egui::TextEdit::singleline(&mut text).id(id.with("te")).frame(egui::Frame::NONE).font(regular(11.0)).desired_width(rect.width() - 16.0)); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if esc { + ui.data_mut(|d| d.remove::(editing_id)); + } else if r.lost_focus() || enter { + out = Some(text); + ui.data_mut(|d| d.remove::(editing_id)); + } else { + ui.data_mut(|d| d.insert_temp(editing_id, text)); + } + } + None => { + let (s, col) = if value.is_empty() { (placeholder, t.text_tertiary) } else { (value, t.text) }; + ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col); + } + } + out +} + +/// A dropdown-looking button showing `label`; returns the response (open a menu on click). +pub fn dropdown(ui: &mut Ui, label: &str, width: f32, lead: Option) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click()); + let bg = if resp.hovered() { t.field } else { t.field }; + ui.painter().rect_filled(rect, CornerRadius::same(5), bg); + if resp.hovered() { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + let mut x = rect.min.x + 8.0; + if let Some(i) = lead { + icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 6.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary); + x += 18.0; + } + let galley = ui.painter().layout(label.to_string(), regular(11.0), t.text, rect.width() - (x - rect.min.x) - 20.0); + ui.painter().galley(pos2(x, rect.center().y - galley.size().y / 2.0), galley, t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 10.0, rect.center().y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + resp +} + +/// A row of icon buttons; returns the index clicked. +pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option, cell: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let w = cell * items.len() as f32; + let (rect, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); + let mut out = None; + for (i, (icon, tip)) in items.iter().enumerate() { + let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H)); + let resp = ui.interact(r, ui.id().with(("seg", i, *tip)), Sense::click()).on_hover_text(*tip); + if active == Some(i) { + ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel); + ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), *icon, if active == Some(i) { t.icon } else { t.icon_secondary }); + if resp.clicked() { + out = Some(i); + } + } + out +} + +/// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which +/// button was clicked. `dim` greys the title (empty sections). +pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + ui.painter().text(pos2(rect.min.x + PAD, rect.center().y), Align2::LEFT_CENTER, title, semibold(11.0), if dim { t.text_secondary } else { t.text }); + let mut out = None; + let mut x = rect.max.x - 8.0; + for (i, (icon, tip)) in buttons.iter().enumerate().rev() { + let r = Rect::from_min_size(pos2(x - 24.0, rect.center().y - 12.0), vec2(24.0, 24.0)); + let resp = ui.interact(r, ui.id().with(("hdr", title, i)), Sense::click()).on_hover_text(*tip); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), r.shrink(5.0), *icon, t.icon); + if resp.clicked() { + out = Some(i); + } + x -= 24.0; + } + out +} + +pub fn separator(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); + ui.painter().line_segment([rect.left_center(), rect.right_center()], Stroke::new(1.0, t.border)); +} + +/// Paint a checkerboard (transparency) in `rect`. +pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) { + painter.rect_filled(rect, CornerRadius::ZERO, Color32::WHITE); + let nx = (rect.width() / cell).ceil() as i32; + let ny = (rect.height() / cell).ceil() as i32; + for y in 0..ny { + for x in 0..nx { + if (x + y) % 2 == 0 { + continue; + } + let r = Rect::from_min_size(pos2(rect.min.x + x as f32 * cell, rect.min.y + y as f32 * cell), vec2(cell, cell)).intersect(rect); + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_gray(0xE6)); + } + } +} + +/// A paint swatch (with checkerboard under transparent colours). +pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + paint_swatch(ui.painter(), rect, paint); + resp +} + +pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color::Paint) { + let t = Tokens::get(painter.ctx()); + use figcraft_color::PaintKind; + let clip = painter.with_clip_rect(rect); + checker(&clip, rect, rect.width() / 4.0); + match &paint.kind { + PaintKind::Solid { color } => { + // Left half opaque, right half with alpha (like Figma's swatches). + let c = crate::theme::c32(color.with_alpha(color.a * paint.opacity)); + let opaque = crate::theme::c32(color.with_alpha(1.0)); + clip.rect_filled(Rect::from_min_max(rect.min, pos2(rect.center().x, rect.max.y)), CornerRadius::ZERO, opaque); + clip.rect_filled(Rect::from_min_max(pos2(rect.center().x, rect.min.y), rect.max), CornerRadius::ZERO, c); + if paint.opacity >= 0.999 && color.a >= 0.999 { + clip.rect_filled(rect, CornerRadius::ZERO, opaque); + } + } + PaintKind::Gradient(g) => { + let n = 16; + for i in 0..n { + let c = crate::theme::c32(g.sample(i as f32 / (n - 1) as f32)); + let x0 = rect.min.x + rect.width() * i as f32 / n as f32; + let x1 = rect.min.x + rect.width() * (i + 1) as f32 / n as f32; + clip.rect_filled(Rect::from_min_max(pos2(x0, rect.min.y), pos2(x1, rect.max.y)), CornerRadius::ZERO, c); + } + } + PaintKind::Image(_) => { + clip.rect_filled(rect, CornerRadius::ZERO, Color32::from_gray(0xB3)); + icons::paint(&clip, rect.shrink(2.0), Icon::Image, Color32::WHITE); + } + } + painter.rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, t.border), StrokeKind::Inside); +} + +/// A small checkbox with label. +pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(200.0), FIELD_H), Sense::click()); + let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0)); + if on { + ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + } else { + ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); + } + ui.painter().text(pos2(b.max.x + 8.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text); + resp.clicked() +} + +/// Text in a row. +pub fn label(ui: &mut Ui, text: &str, color: Color32) { + ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); +} + +#[cfg(test)] +mod tests { + use super::eval; + + #[test] + fn math_in_fields() { + assert_eq!(eval("12", None), Some(12.0)); + assert_eq!(eval("10+5", None), Some(15.0)); + assert_eq!(eval("*2", Some(8.0)), Some(16.0)); + assert_eq!(eval("+4", Some(8.0)), Some(12.0)); + assert_eq!(eval("-3", None), Some(-3.0)); + assert_eq!(eval("50%", None), Some(50.0)); + assert_eq!(eval("1/0", None), None); + assert_eq!(eval("abc", None), None); + } +} + +/// Text shown in `rect` that turns into an editor on click (or when its editor has focus, e.g. +/// after a double-click rename). Returns the new text on Enter / focus loss. +pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId) -> Option { + let t = Tokens::get(ui.ctx()); + let te_id = id.with("te"); + let buf_id = id.with("buf"); + let focused = ui.memory(|m| m.has_focus(te_id)); + let mut buf: Option = ui.data(|d| d.get_temp(buf_id)); + let resp = ui.interact(rect, id.with("click"), Sense::click()); + if buf.is_none() && (focused || resp.clicked()) { + buf = Some(value.to_string()); + ui.memory_mut(|m| m.request_focus(te_id)); + } + match buf { + Some(mut text) => { + ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel); + ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + let r = child.add(egui::TextEdit::singleline(&mut text).id(te_id).frame(egui::Frame::NONE).font(font).desired_width(rect.width())); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if esc { + ui.data_mut(|d| d.remove::(buf_id)); + ui.memory_mut(|m| m.surrender_focus(te_id)); + None + } else if r.lost_focus() || enter { + ui.data_mut(|d| d.remove::(buf_id)); + ui.memory_mut(|m| m.surrender_focus(te_id)); + (text != value && !text.trim().is_empty()).then_some(text) + } else { + ui.data_mut(|d| d.insert_temp(buf_id, text)); + None + } + } + None => { + if resp.hovered() { + ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.hover); + } + let galley = ui.painter().layout(value.to_string(), font, t.text, rect.width()); + ui.painter().with_clip_rect(rect).galley(pos2(rect.min.x, rect.center().y - galley.size().y / 2.0), galley, t.text); + None + } + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..e295eae4 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,8 @@ publish = false [dependencies] serde_json = { workspace = true } +figcraft-doc = { workspace = true } +figcraft-color = { workspace = true } +figcraft-render = { workspace = true } +figcraft-engine = { workspace = true } +image = { workspace = true } diff --git a/xtask/src/icon.rs b/xtask/src/icon.rs new file mode 100644 index 00000000..39bfd676 --- /dev/null +++ b/xtask/src/icon.rs @@ -0,0 +1,101 @@ +//! The app icon, generated from code with FigCraft's own renderer (original artwork, +//! MIT OR Apache-2.0): a rounded tile in the app colour with a paper "frame" mark — three stacked +//! layer cards and a cursor. Writes assets/app-icon/*.png, the hicolor theme and figcraft.ico. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeKind}; + +pub const APP_COLOR: &str = "0EA5C6"; +const INK: &str = "0B0B0C"; +const PAPER: &str = "EFE9DC"; + +fn build() -> Option { + let mut d = Document::new("icon"); + let p = d.first_page()?; + let bg = d.create(p, NodeKind::Rectangle, "tile", 0.0, 0.0, 512.0, 512.0).ok()?; + let n = d.get_mut(bg).ok()?; + n.corners.radii = [112.0; 4]; + n.corners.smoothing = 0.6; + n.fills = vec![Paint::solid(Color::from_hex(APP_COLOR)?)]; + // Three stacked cards (layers), back to front. + for (i, (x, y, w, h)) in [(150.0, 96.0, 250.0, 200.0), (122.0, 150.0, 250.0, 200.0), (94.0, 204.0, 250.0, 200.0)].iter().enumerate() { + let c = d.create(p, NodeKind::Rectangle, "card", *x, *y, *w, *h).ok()?; + let n = d.get_mut(c).ok()?; + n.corners.radii = [28.0; 4]; + n.corners.smoothing = 0.6; + n.fills = vec![Paint::solid(Color::from_hex(PAPER)?)]; + n.strokes = vec![Paint::solid(Color::from_hex(INK)?)]; + n.stroke.weight = 14.0; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + if i == 2 { + // Front card: a frame grid "#". + for (gx, gy, gw, gh) in [(150.0, 228.0, 14.0, 152.0), (250.0, 228.0, 14.0, 152.0), (118.0, 260.0, 178.0, 14.0), (118.0, 330.0, 178.0, 14.0)] { + let g = d.create(p, NodeKind::Rectangle, "grid", gx, gy, gw, gh).ok()?; + let gn = d.get_mut(g).ok()?; + gn.fills = vec![Paint::solid(Color::from_hex(INK)?)]; + gn.corners.radii = [7.0; 4]; + } + } + } + // Cursor. + let cur = figcraft_doc::VectorNetwork::from_points(&[(0.0, 0.0).into(), (0.0, 120.0).into(), (32.0, 92.0).into(), (52.0, 136.0).into(), (72.0, 128.0).into(), (52.0, 84.0).into(), (92.0, 82.0).into()], true); + let c = d.create(p, NodeKind::Vector { network: cur }, "cursor", 318.0, 300.0, 92.0, 136.0).ok()?; + let n = d.get_mut(c).ok()?; + n.fills = vec![Paint::solid(Color::from_hex(INK)?)]; + n.strokes = vec![Paint::solid(Color::from_hex(PAPER)?)]; + n.stroke.weight = 10.0; + n.stroke.align = figcraft_doc::StrokeAlign::Outside; + n.stroke.join = figcraft_doc::StrokeJoin::Round; + Some(d) +} + +pub fn generate(root: &std::path::Path) -> bool { + let Some(doc) = build() else { return false }; + let Some(page) = doc.first_page() else { return false }; + let dir = root.join("assets/app-icon"); + let _ = std::fs::create_dir_all(dir.join("hicolor")); + let render = |size: u32| -> Option { + let view = figcraft_render::View { origin: (0.0, 0.0), scale: f64::from(size) / 512.0, width: size, height: size }; + let opts = figcraft_render::Options { background: Some(Color::TRANSPARENT), ..Default::default() }; + let pix = figcraft_render::render_view(&doc, page, &view, &opts)?; + image::RgbaImage::from_raw(size, size, figcraft_render::to_rgba8(&pix)) + }; + let mut ok = true; + for size in [16u32, 32, 48, 64, 128, 256, 512, 1024] { + let Some(img) = render(size) else { return false }; + let name = if size == 1024 { "figcraft-1024.png".to_string() } else { format!("figcraft-{size}.png") }; + ok &= img.save(dir.join(&name)).is_ok(); + if size <= 512 { + let hd = dir.join(format!("hicolor/{size}x{size}/apps")); + let _ = std::fs::create_dir_all(&hd); + ok &= img.save(hd.join("ai.storyteller.figcraft.png")).is_ok(); + } + } + // Windows .ico (PNG-compressed entries). + let mut ico = Vec::new(); + let sizes = [16u32, 32, 48, 256]; + let pngs: Vec> = sizes + .iter() + .filter_map(|s| { + let img = render(*s)?; + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + }) + .collect(); + ico.extend_from_slice(&[0, 0, 1, 0, pngs.len() as u8, 0]); + let mut offset = 6 + 16 * pngs.len() as u32; + for (s, png) in sizes.iter().zip(&pngs) { + let b = if *s >= 256 { 0 } else { *s as u8 }; + ico.extend_from_slice(&[b, b, 0, 0, 1, 0, 32, 0]); + ico.extend_from_slice(&(png.len() as u32).to_le_bytes()); + ico.extend_from_slice(&offset.to_le_bytes()); + offset += png.len() as u32; + } + for png in &pngs { + ico.extend_from_slice(png); + } + ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok(); + eprintln!("icon: wrote {}", dir.display()); + ok +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs new file mode 100644 index 00000000..fd4dd135 --- /dev/null +++ b/xtask/src/layers.rs @@ -0,0 +1,63 @@ +//! Crate layering: a crate may depend only on lower layers (or earlier crates of its own layer +//! listed in ORDER). Nothing below the UI depends on egui/eframe. + +use std::collections::BTreeMap; +use std::process::Command; + +/// (crate suffix, layer) +const TABLE: &[(&str, u8)] = &[ + ("geom", 0), + ("color", 0), + ("doc", 1), + ("layout", 2), + ("text", 2), + ("pathops", 2), + ("render", 3), + ("format", 3), + ("svg", 4), + ("pdf", 4), + ("engine", 5), + ("ui-egui", 6), + ("mcp", 6), +]; + +pub fn check(root: &std::path::Path) -> bool { + let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(root) + .output(); + let Ok(out) = out else { return false }; + let Ok(v) = serde_json::from_slice::(&out.stdout) else { return false }; + let layer: BTreeMap = TABLE.iter().map(|(n, l)| (format!("figcraft-{n}"), *l)).collect(); + let mut ok = true; + for pkg in v["packages"].as_array().cloned().unwrap_or_default() { + let name = pkg["name"].as_str().unwrap_or("").to_string(); + let Some(&l) = layer.get(&name) else { continue }; + for dep in pkg["dependencies"].as_array().cloned().unwrap_or_default() { + let dn = dep["name"].as_str().unwrap_or(""); + if dep["kind"].as_str() == Some("dev") { + continue; + } + if l < 6 && (dn == "egui" || dn == "eframe" || dn == "rfd" || dn == "winit") { + eprintln!("layers: {name} (L{l}) depends on UI crate {dn}"); + ok = false; + } + if let Some(&dl) = layer.get(dn) + && dl >= l + && !(dl == l && same_layer_ok(&name, dn)) + { + eprintln!("layers: {name} (L{l}) may not depend on {dn} (L{dl})"); + ok = false; + } + } + } + if ok { + eprintln!("layers: ok"); + } + ok +} + +fn same_layer_ok(from: &str, to: &str) -> bool { + // Within L0: color uses geom types; within L2: text/layout/pathops are independent. + matches!((from, to), ("figcraft-color", "figcraft-geom") | ("figcraft-render", "figcraft-format")) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f328e4d9..16ea94cc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1 +1,147 @@ -fn main() {} +//! Project tasks: `cargo xtask `. +//! +//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check +//! - `layers` crate dependency layering check +//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md +//! - `parity` menu-catalog coverage → docs/parity.md +//! - `version` print the version, or `version set X.Y.Z` +//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork) +//! - `wasm` check the core crates and web app for wasm32 + +mod icon; +mod layers; + +use std::process::{Command, ExitCode}; + +fn run(cmd: &mut Command) -> bool { + eprintln!("$ {cmd:?}"); + cmd.status().map(|s| s.success()).unwrap_or(false) +} + +fn cargo() -> Command { + Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) +} + +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(|p| p.to_path_buf()).unwrap_or_default() +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let task = args.first().map(String::as_str).unwrap_or("help"); + let ok = match task { + "ci" => ci(), + "layers" => layers::check(&root()), + "assets" => assets(), + "parity" => parity(), + "version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)), + "icon" => icon::generate(&root()), + "wasm" => wasm(), + _ => { + eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm"); + task == "help" + } + }; + if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE } +} + +fn ci() -> bool { + run(cargo().args(["fmt", "--all", "--check"])) + && run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"])) + && run(cargo().args(["test", "--workspace"])) + && layers::check(&root()) + && assets() + && wasm() +} + +fn wasm() -> bool { + run(cargo().args(["check", "--target", "wasm32-unknown-unknown", "-p", "figcraft-engine", "-p", "figcraft-ui-egui", "-p", "figcraft-web"])) +} + +/// Every asset file must be listed in ATTRIBUTION.md. +fn assets() -> bool { + let root = root(); + let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).unwrap_or_default(); + let mut missing = Vec::new(); + for dir in ["assets", "docs/brand", "docs/images"] { + walk(&root.join(dir), &mut |p| { + let rel = p.strip_prefix(&root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default(); + let name = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + if name.starts_with('.') || name.ends_with(".md") || name.ends_with(".txt") { + return; + } + if !attribution.contains(&rel) && !attribution.contains(&format!("{}/", rel.rsplit_once('/').map(|x| x.0).unwrap_or(""))) { + missing.push(rel); + } + }); + } + if !missing.is_empty() { + eprintln!("assets without an ATTRIBUTION.md row:\n {}", missing.join("\n ")); + return false; + } + eprintln!("assets: ok"); + true +} + +fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, f); + } else { + f(&p); + } + } +} + +fn parity() -> bool { + let extra = ["file.open", "file.import", "file.placeImage", "file.save", "file.saveAs", "file.export", "file.exportSvg", "edit.copyAsText", "edit.copyAsCss", "edit.copyAsSvg", "edit.copyAsPng", "edit.copyLink"]; + let (done, total, missing) = figcraft_engine::catalog::parity(&extra); + let pct = done as f64 * 100.0 / total.max(1) as f64; + let mut md = format!( + "# Parity report\n\nGenerated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar ({} commands in the engine).\n\n**{done} / {total} menu items ({pct:.1}%)**\n\n", + figcraft_engine::all_commands().len() + ); + if missing.is_empty() { + md.push_str("Every catalog item has a command.\n"); + } else { + md.push_str("## Missing\n\n"); + for m in &missing { + md.push_str(&format!("- `{m}`\n")); + } + } + md.push_str("\nMenu coverage measures breadth only; see ROADMAP.md for depth and fidelity.\n"); + let path = root().join("docs/parity.md"); + if let Err(e) = std::fs::write(&path, md) { + eprintln!("{}: {e}", path.display()); + return false; + } + eprintln!("parity: {done}/{total} ({pct:.1}%) → docs/parity.md"); + true +} + +fn version(sub: Option<&str>, value: Option<&str>) -> bool { + let path = root().join("Cargo.toml"); + let Ok(text) = std::fs::read_to_string(&path) else { return false }; + let cur = text.lines().skip_while(|l| !l.starts_with("[workspace.package]")).find_map(|l| l.strip_prefix("version = \"").and_then(|r| r.strip_suffix('"'))).unwrap_or("0.0.0").to_string(); + match (sub, value) { + (Some("set"), Some(v)) => { + let valid = v.split(['-', '+']).next().is_some_and(|core| core.split('.').count() == 3 && core.split('.').all(|p| p.parse::().is_ok())); + if !valid { + eprintln!("version must look like 1.2.3 or 1.2.3-rc.1"); + return false; + } + let new = text.replacen(&format!("version = \"{cur}\""), &format!("version = \"{v}\""), 1); + if std::fs::write(&path, new).is_err() { + return false; + } + eprintln!("version {cur} → {v}"); + run(cargo().args(["update", "--workspace"])) + } + _ => { + println!("{cur}"); + true + } + } +}