diff --git a/Cargo.lock b/Cargo.lock index f1f532735..053950cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,138 @@ # 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" @@ -17,12 +143,267 @@ 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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[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" @@ -35,7 +416,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -44,12 +434,61 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytemuck" version = "1.25.2" @@ -76,12 +515,115 @@ 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" @@ -97,6 +639,75 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -140,12 +751,311 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "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", + "webbrowser", + "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 = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[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 = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust 0.12.0", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu 0.1.0", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -153,9 +1063,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -165,12 +1081,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" @@ -186,6 +1128,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" @@ -218,6 +1166,116 @@ dependencies = [ "bytemuck", ] +[[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 = "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 = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -251,6 +1309,33 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.1.0", +] + [[package]] name = "glifo" version = "0.3.0" @@ -259,13 +1344,105 @@ checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash", - "hashbrown", + "hashbrown 0.17.1", "log", "peniko", "png", "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.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]] @@ -277,19 +1454,54 @@ 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 2.13.2", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + [[package]] name = "harfrust" 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" @@ -299,6 +1511,122 @@ 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" version = "0.25.10" @@ -313,6 +1641,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -327,12 +1656,156 @@ dependencies = [ "quick-error 2.0.1", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -346,24 +1819,85 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" 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 = "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" @@ -376,6 +1910,24 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +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" @@ -406,6 +1958,80 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-traits" version = "0.2.19" @@ -413,6 +2039,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]] @@ -421,6 +2381,26 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[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-channel" version = "1.2.0" @@ -430,6 +2410,54 @@ dependencies = [ "crossbeam-channel", ] +[[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" @@ -443,19 +2471,143 @@ dependencies = [ "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 = "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" @@ -465,6 +2617,30 @@ 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 = "ppv-lite86" version = "0.2.21" @@ -474,6 +2650,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -483,15 +2674,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", "num-traits", "rand", "rand_chacha", @@ -520,6 +2717,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" @@ -579,6 +2785,30 @@ dependencies = [ "rand_core", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" version = "1.12.0" @@ -632,6 +2862,33 @@ 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" @@ -661,19 +2918,92 @@ 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 = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -686,6 +3016,39 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -729,12 +3092,70 @@ 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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -755,6 +3176,21 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -764,6 +3200,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 = "syn" version = "2.0.119" @@ -786,6 +3315,17 @@ 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" @@ -795,8 +3335,26 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -805,7 +3363,18 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -828,6 +3397,134 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -840,6 +3537,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -858,6 +3561,56 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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_common" version = "0.2.0" @@ -871,7 +3624,19 @@ dependencies = [ "peniko", "png", "smallvec", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo 0.2.0", + "hashbrown 0.17.1", + "vello_common 0.1.0", ] [[package]] @@ -882,15 +3647,21 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo", - "hashbrown", + "glifo 0.3.0", + "hashbrown 0.17.1", "ordered-channel", "png", "rayon", "thread_local", - "vello_common", + "vello_common 0.2.0", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -900,6 +3671,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -909,18 +3690,559 @@ 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 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.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" @@ -930,12 +4252,224 @@ 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 = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wordcraft" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "log", + "rfd", + "serde_json", + "wordcraft-doc", + "wordcraft-engine", + "wordcraft-ui-egui", +] + [[package]] name = "wordcraft-doc" version = "0.1.0" @@ -943,7 +4477,7 @@ dependencies = [ "proptest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "unicode-segmentation", "wordcraft-geom", ] @@ -956,7 +4490,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.21", "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", @@ -968,7 +4502,7 @@ dependencies = [ name = "wordcraft-fonts" version = "0.1.0" dependencies = [ - "harfrust", + "harfrust 0.13.3", "kurbo", "log", "skrifa 0.47.0", @@ -1002,13 +4536,100 @@ version = "0.1.0" dependencies = [ "image", "log", - "vello_cpu", + "vello_cpu 0.2.0", "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", "wordcraft-layout", ] +[[package]] +name = "wordcraft-ui-egui" +version = "0.1.0" +dependencies = [ + "egui", + "egui_kittest", + "image", + "log", + "serde", + "serde_json", + "wordcraft-doc", + "wordcraft-engine", + "wordcraft-fonts", + "wordcraft-geom", + "wordcraft-layout", + "wordcraft-render", +] + +[[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 = "xtask" version = "0.1.0" @@ -1016,6 +4637,135 @@ dependencies = [ "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.61" @@ -1036,6 +4786,60 @@ dependencies = [ "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 = "zlib-rs" version = "0.6.8" @@ -1062,3 +4866,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 56a8707dc..c681dba6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/*", "xtask"] -default-members = ["crates/*", "xtask"] +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/wordcraft", "xtask"] [workspace.package] version = "0.1.0" diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml new file mode 100644 index 000000000..83cdbc2dc --- /dev/null +++ b/apps/wordcraft/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "wordcraft" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +default-run = "wordcraft" +description = "WordCraft desktop app" + +[lints] +workspace = true + +[dependencies] +wordcraft-doc = { workspace = true } +wordcraft-engine = { workspace = true } +wordcraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs new file mode 100644 index 000000000..132327280 --- /dev/null +++ b/apps/wordcraft/src/control_server.rs @@ -0,0 +1,62 @@ +//! Loopback JSON-lines control server: one request per line, one reply per line. +//! This is the transport the MCP server (`wordcraft mcp --connect`) wraps. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use wordcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("wordcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + return rx; + } + }; + eprintln!("wordcraft: control server listening on 127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || serve(stream, tx, ctx)); + } + }); + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs new file mode 100644 index 000000000..a14270e1b --- /dev/null +++ b/apps/wordcraft/src/main.rs @@ -0,0 +1,142 @@ +//! WordCraft desktop app. +//! +//! Usage: `wordcraft [--control ] [--sample] [files…]` +//! +//! `--control ` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`. +//! See `docs/control-protocol.md`. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod control_server; + +use wordcraft_engine::Session; +use wordcraft_ui_egui::{Services, UiState, WordApp}; + +struct App(WordApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + if self.0.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } + fn on_exit(&mut self) { + save_prefs(&self.0); + } +} + +fn prefs_path() -> Option { + let base = if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/WordCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("WordCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("wordcraft")) + }; + base.map(|b| b.join("ui.json")) +} + +fn load_prefs(app: &mut WordApp) { + if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() + && let Ok(bytes) = std::fs::read(&p) + && let Ok(ui) = serde_json::from_slice::(&bytes) + { + app.ui = ui; + app.ui.backstage = false; + } +} + +fn save_prefs(app: &WordApp) { + if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + let _ = std::fs::write(&p, bytes); + } + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|purpose: &str| { + let d = rfd::FileDialog::new(); + let d = if purpose == "picture" { + d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) + } else { + d.add_filter("Documents", &["docx", "docm", "dotx", "odt", "rtf", "txt", "md", "html", "htm", "json"]) + .add_filter("Word document", &["docx"]) + .add_filter("All files", &["*"]) + }; + d.pick_file().map(|p| p.to_string_lossy().to_string()) + })), + pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), + } +} + +fn main() -> eframe::Result { + let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut sample = false; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--version" => { + println!("wordcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + _ => files.push(a), + } + } + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("WordCraft") + .with_app_id("ai.storyteller.wordcraft") + .with_inner_size([1440.0, 920.0]) + .with_min_inner_size([760.0, 480.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false), + ..Default::default() + }; + eframe::run_native( + "WordCraft", + options, + Box::new(move |cc| { + let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() }; + let mut app = WordApp::new(Session::new(doc), services()); + load_prefs(&mut app); + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + for f in files { + if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { + eprintln!("wordcraft: {f}: {e}"); + } + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0d88a1b3a..9a96f8c0c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(), CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(), - CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12 / Mod+Shift+S").params(r#"{"path": string}"#).pure(), + CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string}"#).pure(), CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| { let path = p::req_str(v, "path")?; let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d0076b3bc..f7ba20c92 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -41,13 +41,11 @@ pub fn specs() -> Vec { let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0); zoom(s, &json!({"value": z * 100.0})) }) - .key("Mod+Shift+=") .pure(), CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| { let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1); zoom(s, &json!({"value": z * 100.0})) }) - .key("Mod+-") .pure(), CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(), CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(), diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index dea53a137..7304d808a 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -571,7 +571,16 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } _ => {} } - let fits = x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker; + // Text after a right/centre tab grows leftwards into the tab's space first. + let absorbs = pending_tab.is_some_and(|(tj, stop, _)| { + let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0); + match stop.align { + TabAlign::Right | TabAlign::Decimal => room >= c.adv, + TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01, + _ => false, + } + }); + let fits = absorbs || x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker; if !fits && j > c0 { // Wrap: back up to the last break opportunity on this line. end = LineEnd::Wrap; @@ -632,6 +641,10 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } j += 1; } + if end == LineEnd::Para || end == LineEnd::Wrap { + let xc = x; + resolve_tab(pl, &mut pending_tab, &mut xs, c0, xc, &mut x); + } if j >= n && end != LineEnd::Wrap && !matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) { end = LineEnd::Para; } @@ -795,8 +808,27 @@ fn pl_text_at(c: &Cluster) -> bool { c.dot } -fn resolve_tab(_pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, _xs: &mut [f32], _c0: usize, _x: f32, _xo: &mut f32) { - *pending = None; +fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, xs: &mut [f32], c0: usize, x: f32, xo: &mut f32) { + // Finish a centre/right/decimal tab: its text (possibly empty) ends at `x`. + let Some((tj, stop, tx)) = pending.take() else { return }; + let seg = x - tx - pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0); + let want = match stop.align { + TabAlign::Right | TabAlign::Decimal => stop.pos - tx - seg, + TabAlign::Center => stop.pos - tx - seg / 2.0, + _ => return, + } + .max(0.0); + let old = pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0); + let delta = want - old; + if delta.abs() > 0.001 { + if let Some(c) = pl.clusters.get_mut(tj) { + c.adv = want; + } + for v in xs.iter_mut().skip(tj + 1 - c0) { + *v += delta; + } + *xo += delta; + } } impl ParaLayout { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6608568bf..927aaf913 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -226,3 +226,12 @@ fn layout_is_fast() { eprintln!("3000 paragraphs, {} pages: cold {cold:.1} ms, warm {warm:.1} ms", l.pages.len()); assert!(warm < cold); } + +#[test] +fn empty_center_tab_then_right_tab() { + let mut d = Document::from_text("left\t\tright"); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.style = Some("Header".into())).unwrap(); + let l = lay(&d); + let end = l.caret(&Pos::body(0, 11)).unwrap().x; + assert!((end - (72.0 + 468.0)).abs() < 1.0, "{end}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b171ce651..2dd57f326 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -436,3 +436,12 @@ mod tests { } } } + +/// Render the page area (`x`, `y`, `w`×`h` points) at `scale` px/pt — for previews and thumbnails. +pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let pw = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE); + let ph = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE); + let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64)); + render_region(doc, page, pw, ph, view, opts) +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 000000000..66967446a --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "wordcraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothing below depends on it" + +[lints] +workspace = true + +[dependencies] +wordcraft-geom = { workspace = true } +wordcraft-doc = { workspace = true } +wordcraft-fonts = { workspace = true } +wordcraft-layout = { workspace = true } +wordcraft-render = { workspace = true } +wordcraft-engine = { workspace = true } +egui = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +image = { workspace = true } diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs new file mode 100644 index 000000000..8a1278dc9 --- /dev/null +++ b/crates/ui-egui/examples/ui_shot.rs @@ -0,0 +1,84 @@ +//! Headless UI screenshots: renders the whole WordCraft window offscreen (wgpu), so it works +//! with a locked screen or a hidden window. +//! +//! `cargo run -p wordcraft-ui-egui --example ui_shot -- script.jsonl` +//! +//! The script is JSON lines: control-channel requests (`{"method": …, "params": …}`, see +//! `docs/control-protocol.md`), `{"shot": "/abs/out.png"}` to save the window as PNG, or +//! `{"steps": n}` to run extra frames. The window is 1440×900 pt at 2× and opens the sample +//! document unless the first line is `{"empty": true}`. + +use wordcraft_engine::Session; +use wordcraft_ui_egui::{ControlRequest, Services, WordApp}; + +static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn main() { + let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl"); + let script = std::fs::read_to_string(&path).expect("read script"); + let (tx, rx) = std::sync::mpsc::channel::(); + let lines: Vec = script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect(); + let doc = if lines.first().is_some_and(|l| l.get("empty").is_some()) { wordcraft_doc::Document::new() } else { wordcraft_engine::sample::sample_document() }; + let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx); + app.integrated_titlebar = true; + let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32; + let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32; + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(w, h)) + .with_pixels_per_point(2.0) + .with_max_steps(1_000_000) + .wgpu() + .build_ui_state( + |ui, app: &mut WordApp| { + if !READY.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + READY.store(true, std::sync::atomic::Ordering::Relaxed); + step_n(&mut harness, 6); + for l in lines { + if let Some(p) = l.get("shot").and_then(|v| v.as_str()) { + step_n(&mut harness, 4); + match harness.render() { + Ok(img) => { + img.save(p).expect("save png"); + println!("{p}"); + } + Err(e) => eprintln!("render failed: {e}"), + } + } else if let Some(n) = l.get("steps").and_then(|v| v.as_u64()) { + step_n(&mut harness, n as usize); + } else if let Some(m) = l.get("method").and_then(|v| v.as_str()) { + let (req, reply) = ControlRequest::new(m, l.get("params").cloned().unwrap_or_default()); + tx.send(req).expect("send"); + for _ in 0..60 { + step(&mut harness); + if let Ok(r) = reply.try_recv() { + let s = r.to_string(); + println!("{m}: {}", &s[..s.len().min(300)]); + break; + } + } + step_n(&mut harness, 2); + } + } +} + +fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) { + let mut raw = std::mem::take(harness.input_mut()); + harness.state_mut().raw_input_hook(&mut raw); + *harness.input_mut() = raw; + harness.step(); +} + +fn step_n(harness: &mut egui_kittest::Harness<'_, WordApp>, n: usize) { + for _ in 0..n { + step(harness); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 000000000..caecbcc68 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,240 @@ +//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Options, About. + +use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::theme::{APP_COLOR, Tokens, medium, regular, semibold}; +use crate::{WordApp, icons}; + +const PAGES: [(&str, &str); 9] = [ + ("home", "Home"), + ("new", "New"), + ("open", "Open"), + ("info", "Info"), + ("save", "Save"), + ("saveAs", "Save As"), + ("print", "Print"), + ("export", "Export"), + ("options", "Options"), +]; + +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::left("backstage_nav").exact_size(200.0).frame(egui::Frame::NONE.fill(APP_COLOR).inner_margin(egui::Margin { left: 0, right: 0, top: 12, bottom: 12 })).show(ui, |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::click()); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); + if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.backstage = false; + app.canvas.want_focus = true; + } + ui.add_space(6.0); + for (id, label) in PAGES { + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click()); + let active = app.ui.backstage_page == id; + if active { + ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46)); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); + } + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, if active { semibold(14.0) } else { medium(14.0) }, egui::Color32::WHITE); + if resp.clicked() { + match id { + "save" => { + let _ = app.run("file.save", json!({})); + } + "saveAs" => app.save_as_dialog(), + "open" => { + app.ui.backstage_page = id.into(); + } + _ => app.ui.backstage_page = id.into(), + } + } + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + for (label, id) in [("About", "about"), ("Discord community", "discord")] { + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); + } + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(13.0), egui::Color32::WHITE); + if resp.clicked() { + if id == "discord" { + let _ = app.run("ui.discord", json!({})); + } else { + app.dialog = crate::dialogs::Dialog::open("about", app); + } + } + } + }); + }); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() { + "new" | "home" => new_page(app, ui), + "open" => open_page(app, ui), + "info" => info_page(app, ui), + "print" | "export" => export_page(app, ui), + "options" => options_page(app, ui), + _ => new_page(app, ui), + }); + }); +} + +fn heading(ui: &mut Ui, s: &str) { + ui.label(egui::RichText::new(s).font(semibold(26.0))); + ui.add_space(16.0); +} + +fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { + let t = Tokens::get(ui.ctx()); + ui.vertical(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click()); + ui.painter().rect(r, 2.0, egui::Color32::WHITE, Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside); + let ppp = ui.ctx().pixels_per_point(); + let key = format!("tpl:{template}:{ppp}"); + let tex = ui.ctx().data(|d| d.get_temp::(egui::Id::new(&key))).or_else(|| { + let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new()); + let _ = s.run("file.new", &json!({"template": template})); + let l = s.layout(); + let page = l.pages.first()?; + let img = wordcraft_render::render_page(&s.doc, page, 150.0 / page.w * ppp, &Default::default()); + let px = img.to_straight(); + let h = ui.ctx().load_texture(&key, egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px), egui::TextureOptions::LINEAR); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone())); + Some(h) + }); + if let Some(h) = tex { + ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } + ui.label(egui::RichText::new(label).font(regular(12.5))); + if resp.clicked() { + let _ = app.run("file.new", json!({"template": template})); + app.ui.backstage = false; + app.canvas.want_focus = true; + } + }); +} + +fn new_page(app: &mut WordApp, ui: &mut Ui) { + let hour = wordcraft_engine::cmd::now_iso().get(11..13).and_then(|h| h.parse::().ok()).unwrap_or(9); + heading(ui, if hour < 12 { "Good morning" } else if hour < 18 { "Good afternoon" } else { "Good evening" }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 0.0); + template_tile(ui, app, "Blank document", "blank"); + template_tile(ui, app, "Studio handbook (sample)", "sample"); + template_tile(ui, app, "Letter", "letter"); + template_tile(ui, app, "Résumé", "resume"); + template_tile(ui, app, "Report", "report"); + }); + ui.add_space(28.0); + open_list(app, ui); +} + +fn open_list(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("Recent").font(semibold(16.0))); + ui.add_space(6.0); + if app.ui.recent.is_empty() { + ui.label(egui::RichText::new("Documents you open will show up here.").color(t.text_dim)); + } + for p in app.ui.recent.clone() { + let name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, &name, medium(13.0), t.text); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, &p, regular(11.0), t.text_dim); + if resp.clicked() { + let _ = app.run("file.open", json!({"path": p})); + app.ui.backstage = false; + app.canvas.want_focus = true; + } + } +} + +fn open_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, "Open"); + if ui.button(egui::RichText::new("📂 Browse…").font(medium(14.0))).clicked() { + let _ = app.run("ui.openFileDialog", json!({})); + } + ui.add_space(18.0); + open_list(app, ui); +} + +fn info_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, "Info"); + let info = app.session.run("file.info", &json!({})).unwrap_or_default(); + ui.columns(2, |cols| { + let ui = &mut cols[0]; + ui.label(egui::RichText::new("Properties").font(semibold(15.0))); + let mut props = app.session.doc.core.clone(); + let mut changed = false; + egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] { + ui.label(l); + changed |= ui.text_edit_singleline(v).lost_focus(); + ui.end_row(); + } + }); + if changed { + let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})); + } + let ui = &mut cols[1]; + ui.label(egui::RichText::new("Statistics").font(semibold(15.0))); + for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] { + ui.label(format!("{l}: {}", info.get(k).map(|v| v.to_string()).unwrap_or_default())); + } + ui.label(format!("Location: {}", info.get("path").and_then(|v| v.as_str()).unwrap_or("Not saved yet"))); + }); +} + +fn export_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, if app.ui.backstage_page == "print" { "Print" } else { "Export" }); + ui.label("Save a copy in another format. Printing goes through a PDF you can print from any viewer."); + ui.add_space(12.0); + for (label, ext) in [("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), ("Markdown (*.md)", "md"), ("Plain text (*.txt)", "txt"), ("Page image (*.png)", "png")] { + if ui.add(egui::Button::new(egui::RichText::new(label).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() { + let name = format!("{}.{ext}", app.title_stem()); + let picked = app.services.pick_save.as_ref().and_then(|f| f(&name)); + if let Some(path) = picked { + let r = if ext == "png" { app.run("file.exportPng", json!({"path": path})) } else { app.run("file.saveAs", json!({"path": path})) }; + if r.is_ok() { + app.status(format!("Exported {path}")); + } + } + } + ui.add_space(4.0); + } +} + +fn options_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, "Options"); + ui.label(egui::RichText::new("General").font(semibold(15.0))); + ui.horizontal(|ui| { + ui.label("User name:"); + let mut n = app.session.author.clone(); + if ui.text_edit_singleline(&mut n).changed() { + app.session.author = n; + } + }); + let mut dark = app.ui.dark; + if ui.checkbox(&mut dark, "Dark mode").changed() { + let _ = app.run("ui.dark", json!({"value": dark})); + } + ui.checkbox(&mut app.autosave, "AutoSave documents that have been saved"); + ui.checkbox(&mut app.ui.show_discord, "Show the community button in the title bar"); + ui.add_space(10.0); + ui.label(egui::RichText::new("Display").font(semibold(15.0))); + let mut marks = app.session.view.marks; + if ui.checkbox(&mut marks, "Show all formatting marks").changed() { + let _ = app.run("view.marks", json!({"value": marks})); + } + let mut ruler = app.session.view.ruler; + if ui.checkbox(&mut ruler, "Show rulers").changed() { + let _ = app.run("view.ruler", json!({"value": ruler})); + } + ui.add_space(10.0); + ui.label(egui::RichText::new("Agents").font(semibold(15.0))); + ui.label("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel."); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 000000000..a35cac77e --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,558 @@ +//! The document canvas: pages on a grey background, rendered by the engine into textures (one +//! per page, re-rendered only when the page's content changes), with the caret, selection, +//! rulers and mouse editing drawn by egui on top. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; + +use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; +use serde_json::json; +use wordcraft_doc::{Pos, StoryRef}; +use wordcraft_layout::{DocLayout, Page, Placed}; + +use crate::WordApp; +use crate::theme::{Tokens, regular}; + +/// Points on screen per document point at 100% (96 px per inch, like a word processor). +pub const PX_PER_PT: f32 = 96.0 / 72.0; +const GAP: f32 = 18.0; +const RULER: f32 = 22.0; + +pub struct CanvasState { + textures: HashMap, + pub scroll_to_caret: bool, + pub caret_visible_since: f64, + dragging: bool, + pub last_highlight: String, + pub last_font_color: String, + pub last_shading: String, + pub open_url: Option, + /// Screen rects of pages last frame (for the control channel: page ↔ screen coordinates). + pub page_rects: Vec, + /// Effective scale (screen points per document point). + pub scale: f32, + pub canvas_rect: Option, + pub focused: bool, + pub render_ms: f64, + pub ime_preedit: String, + pub want_focus: bool, +} + +impl Default for CanvasState { + fn default() -> Self { + CanvasState { + textures: HashMap::new(), + scroll_to_caret: true, + caret_visible_since: 0.0, + dragging: false, + last_highlight: "yellow".into(), + last_font_color: "C00000".into(), + last_shading: "FFF2CC".into(), + open_url: None, + page_rects: Vec::new(), + scale: PX_PER_PT, + canvas_rect: None, + focused: false, + render_ms: 0.0, + ime_preedit: String::new(), + want_focus: true, + } + } +} + +/// Page positions in content space (points), for the current zoom. +pub struct Geometry { + pub rects: Vec, + pub size: egui::Vec2, + pub scale: f32, +} + +pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { + let v = &app.session.view; + let maxw = l.pages.iter().map(|p| p.w).fold(0.0f32, f32::max).max(72.0); + let maxh = l.pages.iter().map(|p| p.h.min(20_000.0)).fold(0.0f32, f32::max).max(72.0); + let web = v.mode != wordcraft_layout::ViewMode::Print; + let mut scale = v.zoom.clamp(0.1, 5.0) * PX_PER_PT; + let cols = if v.multi_page || v.read_mode { 2 } else { 1 }; + match v.fit.as_str() { + "pageWidth" => scale = ((avail.x - 2.0 * GAP - 20.0) / maxw).clamp(0.1, 6.0), + "onePage" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 2.0 * GAP) / maxw).clamp(0.05, 6.0), + "multiplePages" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0), + _ => {} + } + if v.read_mode { + scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0); + } + if web { + scale = PX_PER_PT * v.zoom.clamp(0.1, 5.0); + } + let mut rects = Vec::with_capacity(l.pages.len()); + let row_w = cols as f32 * maxw * scale + (cols as f32 - 1.0) * GAP; + let content_w = (row_w + 2.0 * GAP).max(avail.x); + let mut y = GAP; + for (i, chunk) in l.pages.chunks(cols).enumerate() { + let _ = i; + let h = chunk.iter().map(|p| p.h.min(1e6) * scale).fold(0.0f32, f32::max); + let mut x = (content_w - row_w) / 2.0; + for p in chunk { + rects.push(Rect::from_min_size(pos2(x, y), vec2(p.w * scale, p.h.min(1e6) * scale))); + x += maxw * scale + GAP; + } + y += h + GAP; + } + Geometry { rects, size: vec2(content_w, y.max(avail.y)), scale } +} + +/// Fingerprint of a page's content for the texture cache. +fn page_key(app: &WordApp, page: &Page, scale_px: f32) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + scale_px.to_bits().hash(&mut h); + let v = &app.session.view; + (v.marks, v.show_markup).hash(&mut h); + let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(id) if Some(id) == page.header_story || Some(id) == page.footer_story); + editing_hf.hash(&mut h); + format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h); + (page.w.to_bits(), page.h.to_bits()).hash(&mut h); + for list in [&page.items, &page.header, &page.footer] { + list.len().hash(&mut h); + for it in list.iter() { + match it { + Placed::Lines { para, l0, l1, x, y, .. } => { + (std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits()).hash(&mut h); + } + Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h), + Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h), + Placed::Image { rect, media, .. } => format!("{rect:?}{media}").hash(&mut h), + Placed::Shape { rect, kind, fill, stroke, .. } => format!("{rect:?}{kind:?}{fill:?}{stroke:?}").hash(&mut h), + Placed::Cell { .. } => {} + } + } + } + app.session.doc.media.len().hash(&mut h); + h.finish() +} + +fn to_screen(origin: Pos2, page_rect: Rect, scale: f32, x: f32, y: f32) -> Pos2 { + pos2(origin.x + page_rect.min.x + x * scale, origin.y + page_rect.min.y + y * scale) +} + +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let layout = app.session.layout(); + let full = ui.available_rect_before_wrap(); + let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode; + let (hruler, vruler, area) = if show_ruler { + let h = Rect::from_min_max(pos2(full.min.x + RULER, full.min.y), pos2(full.max.x, full.min.y + RULER)); + let v = Rect::from_min_max(pos2(full.min.x, full.min.y + RULER), pos2(full.min.x + RULER, full.max.y)); + (Some(h), Some(v), Rect::from_min_max(pos2(full.min.x + RULER, full.min.y + RULER), full.max)) + } else { + (None, None, full) + }; + app.canvas.canvas_rect = Some(area); + let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0)); + app.canvas.scale = geo.scale; + let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint); + if let Some(c) = caret { + app.session.page_hint = c.page; + } + let mut scroll_target: Option = None; + if app.canvas.scroll_to_caret + && let Some(c) = caret + && let Some(pr) = geo.rects.get(c.page) + { + let r = Rect::from_min_size(pos2(pr.min.x + c.x * geo.scale, pr.min.y + c.top * geo.scale), vec2(2.0, c.height * geo.scale)); + scroll_target = Some(r.expand2(vec2(40.0, 60.0))); + } + app.canvas.scroll_to_caret = false; + let mut origin = area.min; + let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); + let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| { + origin = ui.min_rect().min - viewport.min.to_vec2(); + let content = Rect::from_min_size(ui.min_rect().min, geo.size); + let resp = ui.allocate_rect(content, Sense::click_and_drag()); + if let Some(tr) = scroll_target { + ui.scroll_to_rect(Rect::from_min_size(ui.min_rect().min + tr.min.to_vec2(), tr.size()), None); + } + let painter = ui.painter_at(ui.clip_rect()); + let ppp = ui.ctx().pixels_per_point(); + let max_tex = ui.ctx().input(|i| i.max_texture_side) as f32; + let mut rendered = 0; + let t0 = crate::now_ms(); + let mut rects = Vec::with_capacity(geo.rects.len()); + let content_origin = ui.min_rect().min; + for (i, pr) in geo.rects.iter().enumerate() { + let sr = pr.translate(content_origin.to_vec2()); + rects.push(sr); + if !sr.intersects(ui.clip_rect().expand(200.0)) { + continue; + } + let Some(page) = layout.pages.get(i) else { continue }; + // Shadow and paper. + painter.rect_filled(sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); + painter.rect_filled(sr, 0.0, Color32::WHITE); + let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05); + let key = page_key(app, page, scale_px); + let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); + if !fresh && (rendered < 2 || app.canvas.textures.get(&i).is_none() && rendered < 4) { + let mut opts = wordcraft_render::RenderOptions::default(); + opts.display.marks = app.session.view.marks; + opts.display.markup = app.session.view.show_markup; + let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(_)); + opts.display.dim_header = !editing_hf; + opts.display.dim_body = editing_hf; + let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts); + let px = img.to_straight(); + let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px); + match app.canvas.textures.get_mut(&i) { + Some((k, tex)) => { + tex.set(ci, egui::TextureOptions::LINEAR); + *k = key; + } + None => { + let tex = ui.ctx().load_texture(format!("page{i}"), ci, egui::TextureOptions::LINEAR); + app.canvas.textures.insert(i, (key, tex)); + } + } + rendered += 1; + } else if !fresh { + ui.ctx().request_repaint(); + } + if let Some((_, tex)) = app.canvas.textures.get(&i) { + painter.image(tex.id(), sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + // Header/footer editing chrome. + if let StoryRef::Part(id) = app.session.sel.focus.story { + let sect_body = page.body; + if page.header_story == Some(id) || page.footer_story == Some(id) { + let hy = sr.min.y + sect_body.y * geo.scale - 4.0; + let fy = sr.min.y + sect_body.bottom() * geo.scale + 4.0; + for (y, label) in [(hy, "Header"), (fy, "Footer")] { + dashed(&painter, pos2(sr.min.x, y), pos2(sr.max.x, y), Stroke::new(1.0, t.accent)); + let tr = Rect::from_min_size(pos2(sr.min.x + 2.0, if label == "Header" { y } else { y - 18.0 }), vec2(52.0, 18.0)); + painter.rect_filled(tr, 2.0, t.checked); + painter.text(tr.center(), egui::Align2::CENTER_CENTER, label, regular(11.0), t.accent_text); + } + } + } + // Table gridlines. + if app.session.view.gridlines { + for it in &page.items { + if let Placed::Cell { rect, .. } = it { + let r = Rect::from_min_size(pos2(sr.min.x + rect.x * geo.scale, sr.min.y + rect.y * geo.scale), vec2(rect.w * geo.scale, rect.h * geo.scale)); + painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle); + } + } + } + } + if rendered > 0 { + app.canvas.render_ms = crate::now_ms() - t0; + } + // Drop textures of pages that no longer exist. + let n = layout.pages.len(); + app.canvas.textures.retain(|k, _| *k < n); + app.canvas.page_rects = rects.clone(); + // Selection. + if !app.session.sel.is_collapsed() { + let (a, b) = app.session.sel.ordered(); + for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) { + if let Some(pr) = rects.get(pi) { + let sr = Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale)); + painter.rect_filled(sr, 0.0, t.selection); + } + } + } + // Caret. + let focused = resp.has_focus() || app.canvas.focused; + if let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint) + && let Some(pr) = rects.get(c.page) + { + let x = pr.min.x + c.x * geo.scale; + let y0 = pr.min.y + c.top * geo.scale; + let y1 = y0 + c.height * geo.scale; + let since = crate::now_ms() - app.canvas.caret_visible_since; + let on = (since / 530.0) as u64 % 2 == 0; + if app.session.sel.is_collapsed() && on && focused { + painter.line_segment([pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)], Stroke::new(1.5, t.caret)); + } + if focused { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530))); + } + // IME candidate window placement. + let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1)); + if focused { + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false }); + }); + } + if !app.canvas.ime_preedit.is_empty() { + let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * geo.scale * 0.8), t.caret); + painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, t.caret)); + } + } + (resp, rects) + }); + let (resp, rects) = out.inner; + // Focus: the canvas takes keyboard focus on click and keeps Tab/arrows. + if resp.clicked() || resp.drag_started() || app.canvas.want_focus { + resp.request_focus(); + app.canvas.want_focus = false; + } + if resp.has_focus() { + ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); + } + app.canvas.focused = resp.has_focus(); + mouse(app, ui, &resp, &rects, &layout, geo.scale); + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + if app.canvas.focused { + crate::keys::canvas_events(app, ui.ctx()); + } + let _ = origin; + if let (Some(h), Some(v)) = (hruler, vruler) { + rulers(app, ui, h, v, &rects, &layout, geo.scale); + } +} + +fn dashed(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) { + p.extend(egui::Shape::dashed_line(&[a, b], s, 4.0, 3.0)); +} + +/// Page index and document coordinates under a screen point. +pub fn page_at(rects: &[Rect], scale: f32, p: Pos2) -> Option<(usize, f32, f32)> { + let mut best: Option<(usize, f32)> = None; + for (i, r) in rects.iter().enumerate() { + let d = if r.contains(p) { + 0.0 + } else { + let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0); + let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0); + dx + dy * 2.0 + }; + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((i, d)); + } + } + let (i, _) = best?; + let r = rects.get(i)?; + Some((i, (p.x - r.min.x) / scale, (p.y - r.min.y) / scale)) +} + +fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) { + let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; + let Some((page, x, y)) = page_at(rects, scale, p) else { return }; + let mods = ui.input(|i| i.modifiers); + let story = app.session.sel.focus.story; + // Double-click in the header/footer area edits it; double-click in the body leaves it. + if resp.double_clicked() { + if let Some((s, header)) = layout.header_footer_at(page, y) + && story == StoryRef::Body + { + let id = if header { "insert.editHeader" } else { "insert.editFooter" }; + let _ = app.run(id, json!({})); + let _ = s; + if let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + return; + } + if matches!(story, StoryRef::Part(_)) && layout.header_footer_at(page, y).is_none() { + let _ = app.run("insert.closeHeader", json!({})); + if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + return; + } + let _ = app.run("select.word", json!({})); + return; + } + if resp.triple_clicked() { + let _ = app.run("select.paragraph", json!({})); + return; + } + let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer(); + if pressed { + let Some(pos) = layout.hit(page, x, y, story) else { return }; + // Ctrl/⌘+click follows a hyperlink. + if mods.command + && let Some(link) = app.session.doc.para_at(&pos).map(|pp| pp.props_of_char(pos.off).link.clone()).flatten() + { + if let Some(name) = link.strip_prefix('#') { + let _ = app.run("edit.goto", json!({"bookmark": name})); + } else { + app.canvas.open_url = Some(link); + } + return; + } + let pj = serde_json::to_value(&pos).unwrap_or_default(); + let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift})); + if mods.command && !mods.shift { + let _ = app.run("select.sentence", json!({})); + } + app.session.page_hint = page; + app.canvas.dragging = true; + // Format painter applies on mouse up. + } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) { + if let Some(pos) = layout.hit(page, x, y, story) + && pos != app.session.sel.focus + { + app.session.sel.focus = pos; + app.session.page_hint = page; + app.canvas.caret_visible_since = crate::now_ms(); + } + // Auto-scroll near the edges is handled by egui's scroll area drag. + } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) { + app.canvas.dragging = false; + if app.session.painter.is_some() && !app.session.sel.is_collapsed() { + let _ = app.run("edit.pasteFormat", json!({})); + } + } + // Hover a link: show its target. + if resp.hovered() + && !app.canvas.dragging + && let Some(pos) = layout.hit(page, x, y, story) + && let Some(link) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone()) + { + let tip = format!("{link}\n{}+Click to follow link", if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" }); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tip); + }); + } +} + +/// Horizontal and vertical rulers for the caret's page and paragraph. +fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layout: &DocLayout, scale: f32) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(Rect::from_min_max(pos2(v.min.x, h.min.y), pos2(v.max.x, h.max.y)), 0.0, t.canvas); + p.rect_filled(h, 0.0, t.canvas); + p.rect_filled(v, 0.0, t.canvas); + let pi = app.session.page_hint.min(layout.pages.len().saturating_sub(1)); + let (Some(page), Some(pr)) = (layout.pages.get(pi), rects.get(pi)) else { return }; + let sect = app.session.doc.sections().get(page.section).map(|(_, s)| (*s).clone()).unwrap_or_default(); + // Horizontal. + let hp = ui.painter_at(h); + let x0 = pr.min.x; + let bar = Rect::from_min_max(pos2(x0, h.min.y + 4.0), pos2(x0 + page.w * scale, h.max.y - 3.0)); + hp.rect_filled(bar, 0.0, t.ruler_margin); + let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y)); + hp.rect_filled(text, 0.0, t.ruler); + let unit = 72.0; + let origin = page.body.x; + let mut k = -((origin / unit).ceil() as i32) * 8; + loop { + let xpt = origin + k as f32 * unit / 8.0; + if xpt > page.w { + break; + } + if xpt >= 0.0 { + let sx = x0 + xpt * scale; + let (len, label) = if k % 8 == 0 { (0.0, Some(k / 8)) } else if k % 4 == 0 { (5.0, None) } else if k % 2 == 0 { (3.0, None) } else { (1.5, None) }; + match label { + Some(n) if n != 0 => { + hp.text(pos2(sx, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick); + } + Some(_) => {} + None => { + if scale * unit / 8.0 > 4.0 || k % 2 == 0 { + hp.line_segment([pos2(sx, bar.center().y - len / 2.0), pos2(sx, bar.center().y + len / 2.0)], Stroke::new(1.0, t.ruler_tick)); + } + } + } + } + k += 1; + if k > 400 { + break; + } + } + // Indent markers for the caret's paragraph (relative to its column). + let col_x = page.body.x; + if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) { + let rp = app.session.doc.styles.resolve_para(¶.props); + let first = x0 + (col_x + rp.indent_left + rp.indent_first) * scale; + let left = x0 + (col_x + rp.indent_left) * scale; + let right = x0 + (col_x + page.body.w - rp.indent_right) * scale; + let c = t.text_dim; + hp.add(egui::Shape::convex_polygon(vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)], t.ruler, Stroke::new(1.0, c))); + hp.add(egui::Shape::convex_polygon(vec![pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y - 3.0), pos2(left, bar.max.y - 8.0)], t.ruler, Stroke::new(1.0, c))); + hp.rect(Rect::from_min_max(pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y + 1.0)), 0.0, t.ruler, Stroke::new(1.0, c), egui::StrokeKind::Inside); + hp.add(egui::Shape::convex_polygon(vec![pos2(right - 4.5, bar.max.y - 1.0), pos2(right + 4.5, bar.max.y - 1.0), pos2(right, bar.max.y - 6.0)], t.ruler, Stroke::new(1.0, c))); + for tab in &rp.tabs { + let tx = x0 + (col_x + tab.pos) * scale; + hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); + hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + 4.0, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); + } + // Dragging the left-indent marker. + let id = ui.id().with("ruler_left"); + let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0)); + let r = ui.interact(mr, id, Sense::drag()); + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let pt = ((pp.x - x0) / scale - col_x).clamp(-col_x, page.body.w - 18.0); + let snapped = (pt / 4.5).round() * 4.5; + let _ = app.run("para.indents", json!({"left": snapped})); + } + let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0)); + let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::drag()); + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let pt = (pp.x - x0) / scale - col_x - rp.indent_left; + let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5})); + } + let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0)); + let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::drag()); + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let pt = page.body.w - ((pp.x - x0) / scale - col_x); + let _ = app.run("para.indents", json!({"right": (pt / 4.5).round() * 4.5})); + } + } + // Vertical. + let vp = ui.painter_at(v); + let y0 = pr.min.y; + let vbar = Rect::from_min_max(pos2(v.min.x + 4.0, y0), pos2(v.max.x - 3.0, y0 + page.h.min(20_000.0) * scale)); + vp.rect_filled(vbar, 0.0, t.ruler_margin); + vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, y0 + page.body.y * scale), pos2(vbar.max.x, y0 + page.body.bottom() * scale)), 0.0, t.ruler); + let origin = page.body.y; + let mut k = -((origin / unit).ceil() as i32) * 8; + loop { + let ypt = origin + k as f32 * unit / 8.0; + if ypt > page.h.min(20_000.0) { + break; + } + if ypt >= 0.0 && k % 8 == 0 && k != 0 { + vp.text(pos2(vbar.center().x, y0 + ypt * scale), egui::Align2::CENTER_CENTER, (k / 8).abs().to_string(), regular(9.5), t.ruler_tick); + } else if ypt >= 0.0 && k % 4 == 0 { + let sy = y0 + ypt * scale; + vp.line_segment([pos2(vbar.center().x - 2.5, sy), pos2(vbar.center().x + 2.5, sy)], Stroke::new(1.0, t.ruler_tick)); + } + k += 1; + if k > 2000 { + break; + } + } + let _ = sect; +} + +/// Convert a page position (points) to screen coordinates (for agents and tests). +pub fn page_to_screen(app: &WordApp, page: usize, x: f32, y: f32) -> Option { + let r = app.canvas.page_rects.get(page)?; + Some(to_screen(pos2(0.0, 0.0), *r, app.canvas.scale, x, y)) +} + +/// Caret position on screen. +pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> { + let l = app.session.layout(); + let c = l.caret_on(&app.session.sel.focus, app.session.page_hint)?; + let p = page_to_screen(app, c.page, c.x, c.top)?; + Some((p, c.height * app.canvas.scale)) +} + +pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option { + let (page, x, y) = page_at(&app.canvas.page_rects, app.canvas.scale, p)?; + let story = app.session.sel.focus.story; + app.session.layout().hit(page, x, y, story) +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs new file mode 100644 index 000000000..3076aa71c --- /dev/null +++ b/crates/ui-egui/src/chrome.rs @@ -0,0 +1,217 @@ +//! Title bar (Quick Access Toolbar, title, search, account) and status bar. + +use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use wordcraft_doc::StoryRef; + +use crate::theme::{Tokens, medium, regular, semibold}; +use crate::{WordApp, icons}; + +fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click()); + if resp.hovered() && enabled { + ui.painter().rect_filled(r, 4.0, t.hover); + } + let c = if enabled { t.icon } else { t.text_disabled }; + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, c, if enabled { t.accent } else { c }); + let sc = crate::widgets::shortcut_text(app, id); + let resp = resp.on_hover_text(if sc.is_empty() { tip.to_string() } else { format!("{tip} ({sc})") }); + if resp.clicked() && enabled { + let _ = app.run(id, json!({})); + } +} + +pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let left = if app.integrated_titlebar { 78 } else { 8 }; + egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })).show(ui, |ui| { + let full = ui.max_rect(); + // Drag the window by the title bar (integrated title bar on macOS). + let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let mut qat_end = full.min.x; + let mut right_start = full.max.x; + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + // AutoSave toggle (on when the document has a path). + let has_path = app.session.path.is_some(); + ui.label(egui::RichText::new("AutoSave").font(regular(11.5)).color(t.text_dim)); + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + let on = has_path && app.canvas_autosave(); + ui.painter().rect(r, 8.0, if on { t.accent } else { t.input }, Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside); + let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 }; + ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim }); + if resp.on_hover_text("AutoSave saves every change to the file (needs a saved document)").clicked() { + if has_path { + app.toggle_autosave(); + } else { + app.save_as_dialog(); + } + } + ui.add_space(8.0); + qat_button(ui, app, "save", "Save", "file.save", true); + let can_undo = app.session.can_undo(); + let can_redo = app.session.can_redo(); + qat_button(ui, app, "undo", &format!("Undo {}", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo); + qat_button(ui, app, "redo", "Redo", "edit.redo", can_redo); + qat_button(ui, app, "more", "Customize Quick Access Toolbar", "ui.dialog", true); + qat_end = ui.min_rect().max.x; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Account / community. + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + ui.painter().circle_filled(r.center(), 12.0, t.accent); + let initials: String = app.session.author.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect(); + ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE); + resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)); + ui.add_space(8.0); + if app.ui.show_discord { + let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click()); + ui.painter().rect_filled(r, 11.0, if resp.hovered() { egui::Color32::from_rgb(0x47, 0x52, 0xC4) } else { egui::Color32::from_rgb(0x58, 0x65, 0xF2) }); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)), "discord", egui::Color32::WHITE, egui::Color32::WHITE); + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, "Discord", medium(11.5), egui::Color32::WHITE); + if resp.on_hover_text("Join the ArtCraft community on Discord").clicked() { + let _ = app.run("ui.discord", json!({})); + } + ui.add_space(8.0); + } + // Search ("Tell me"). + let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click()); + ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "Search commands and help", regular(12.0), t.text_dim); + if resp.clicked() { + let _ = app.run("ui.dialog", json!({"name": "commands"})); + } + right_start = ui.min_rect().min.x; + }); + }); + // Centred title. + let title = format!("{} — {}", app.title_stem(), if app.session.dirty { "Edited" } else if app.session.path.is_some() { "Saved" } else { "Not saved" }); + let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text)); + let w = g.size().x; + let cx = full.center().x; + if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 { + ui.painter().galley(pos2(cx - w / 2.0, full.center().y - g.size().y / 2.0), g, t.text); + } + }); +} + +pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 0.0); + let l = app.session.layout(); + let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1); + let st = |ui: &mut Ui, s: &str| ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())); + if st(ui, &format!("Page {page} of {}", l.pages.len())).on_hover_text("Go To (⌘⌥G)").clicked() { + let _ = app.run("ui.dialog", json!({"name": "goto"})); + } + let words = app.cached_word_count(); + let wtxt = if app.session.sel.is_collapsed() { + format!("{words} words") + } else { + let sw = wordcraft_doc::count_words(&app.session.selected_text()); + format!("{sw} of {words} words") + }; + if st(ui, &wtxt).clicked() { + let _ = app.run("ui.dialog", json!({"name": "wordCount"})); + } + if app.session.sel.focus.story != StoryRef::Body { + st(ui, "Editing header/footer"); + } + st(ui, "English (United States)"); + if app.session.doc.settings.track_changes { + st(ui, "Track Changes: On"); + } + if let Some((msg, at)) = &app.status_msg { + if crate::now_ms() - at < 6000.0 { + ui.label(egui::RichText::new(msg).font(regular(11.5)).color(t.accent_text)); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round(); + if ui.add(egui::Label::new(egui::RichText::new(format!("{z}%")).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())).clicked() { + let _ = app.run("ui.dialog", json!({"name": "zoom"})); + } + if small_icon(ui, "plus", "Zoom In") { + let _ = app.run("view.zoomIn", json!({})); + } + // Zoom slider (10%–500%, 100% in the middle). + let mut v = app.canvas.scale / crate::canvas::PX_PER_PT; + let slider = egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false); + ui.spacing_mut().slider_width = 110.0; + if ui.add(slider).changed() { + let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()})); + } + if small_icon(ui, "minus", "Zoom Out") { + let _ = app.run("view.zoomOut", json!({})); + } + ui.add_space(10.0); + let mode = app.session.view.mode; + let read = app.session.view.read_mode; + for (icon, id, on) in [ + ("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web), + ("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read), + ("readMode", "view.readMode", read), + ] { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 3.0, t.checked); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); + if resp.on_hover_text(id.trim_start_matches("view.")).clicked() { + let _ = app.run(id, json!({})); + } + } + ui.add_space(8.0); + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent); + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, "Focus", regular(11.5), t.text_dim); + if resp.clicked() { + let _ = app.run("view.focus", json!({})); + } + }); + }); + }); +} + +fn small_icon(ui: &mut Ui, icon: &str, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); + resp.on_hover_text(tip).clicked() +} + +impl WordApp { + pub fn canvas_autosave(&self) -> bool { + self.autosave + } + pub fn toggle_autosave(&mut self) { + self.autosave = !self.autosave; + } + /// Word count, recomputed only when the document changes. + pub fn cached_word_count(&mut self) -> usize { + let rev = self.session.rev(); + if self.word_count.0 != rev { + self.word_count = (rev, self.session.doc.word_count()); + } + self.word_count.1 + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 000000000..3a0118fa7 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,218 @@ +//! Programmatic control of the running app (agents, tests, MCP bridge). +//! +//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): +//! - `engine.execute {command, params}`: run any command (engine or `ui.*`) +//! - `engine.commands`: every command with its enablement +//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf +//! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`: +//! real pointer input in screen points (reaches every widget) +//! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left) +//! - `ui.key {key, shift?, alt?, cmd?}`, `ui.text {text}`: keyboard input through egui +//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, page?, scale?}`: page PNG +//! - `ui.parity`: feature catalog parity + +use std::sync::mpsc::Sender; + +use serde_json::{Value, json}; + +use crate::WordApp; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + (Self { method: method.into(), params, reply: tx }, rx) + } +} + +pub enum Outcome { + Done(Value), + Screenshot { path: Option }, +} + +fn ok(v: Value) -> Outcome { + Outcome::Done(json!({"ok": true, "result": v})) +} +fn err(e: impl std::fmt::Display) -> Outcome { + Outcome::Done(json!({"ok": false, "error": e.to_string()})) +} + +pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { + let r = ctx.content_rect(); + let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h})); + json!({ + "ui": app.ui, + "view": app.session.view, + "dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), + "window": [r.width(), r.height()], + "canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]), + "pages": app.canvas.page_rects.iter().map(|r| [r.left(), r.top(), r.width(), r.height()]).collect::>(), + "scale": app.canvas.scale, + "caret": caret, + "focused": app.canvas.focused, + "title": app.title_stem(), + "dirty": app.session.dirty, + "perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms}, + }) +} + +fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" => Some(egui::Key::Delete), + "backspace" => Some(egui::Key::Backspace), + "left" => Some(egui::Key::ArrowLeft), + "right" => Some(egui::Key::ArrowRight), + "up" => Some(egui::Key::ArrowUp), + "down" => Some(egui::Key::ArrowDown), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + "home" => Some(egui::Key::Home), + "end" => Some(egui::Key::End), + _ => None, + }) +} + +fn mods(p: &Value) -> egui::Modifiers { + let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let cmd = b("cmd") || b("command") || b("ctrl"); + egui::Modifiers { alt: b("alt"), ctrl: cmd && !cfg!(target_os = "macos"), shift: b("shift"), mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd } +} + +fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, count: u64, m: egui::Modifiers) { + app.synthetic.push(egui::Event::PointerMoved(pos)); + for _ in 0..count.clamp(1, 3) { + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + } +} + +pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32); + match req.method.as_str() { + "engine.execute" | "command" => { + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + match app.run(id, params) { + Ok(v) => ok(v), + Err(e) => err(e), + } + } + "engine.commands" => { + let reg = app.session.registry.clone(); + ok(Value::Array( + reg.all() + .iter() + .map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()})) + .collect(), + )) + } + "document.inspect" => match app.session.run("document.inspect", p) { + Ok(v) => ok(v), + Err(e) => err(e), + }, + "ui.inspect" => ok(inspect(app, ctx)), + "ui.parity" => ok(wordcraft_engine::catalog::parity(&app.session.registry)), + "ui.click" => { + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + let button = match s("button") { + Some("right") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + click_events(app, egui::pos2(x, y), button, count, mods(p)); + ok(json!({"queued": true})) + } + "ui.clickText" => { + let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + let Some(pos) = crate::canvas::page_to_screen(app, page, x, y) else { return err("page not on screen") }; + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + click_events(app, pos, egui::PointerButton::Primary, count, mods(p)); + ok(json!({"screen": [pos.x, pos.y]})) + } + "ui.move" => { + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); + ok(json!({"queued": true})) + } + "ui.drag" => { + let (Some(x), Some(y), Some(tx), Some(ty)) = (f("x"), f("y"), f("toX"), f("toY")) else { return err("missing x/y/toX/toY") }; + let m = mods(p); + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 100); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); + app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(x, y), button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + for i in 1..=steps { + let k = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * k, y + (ty - y) * k))); + } + app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(tx, ty), button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + ok(json!({"queued": true})) + } + "ui.key" => { + let Some(k) = s("key").and_then(key_from) else { return err("unknown key") }; + let m = mods(p); + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + ok(json!({"queued": true})) + } + "ui.text" => { + let Some(t) = s("text") else { return err("missing text") }; + app.synthetic.push(egui::Event::Text(t.to_string())); + ok(json!({"queued": true})) + } + "ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) }, + "ui.render" => { + let path = s("path").unwrap_or("/tmp/wordcraft-page.png"); + let page = p.get("page").and_then(Value::as_u64).unwrap_or(1); + match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": f("scale").unwrap_or(2.0)})) { + Ok(v) => ok(v), + Err(e) => err(e), + } + } + "ui.resize" => { + let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ok(json!({})) + } + "ui.focus" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + app.canvas.want_focus = true; + ok(json!({})) + } + "app.quit" => { + app.quit_requested = true; + ok(json!({})) + } + other => { + // Anything else: a command id. + match app.run(other, p.clone()) { + Ok(v) => ok(v), + Err(e) => err(e), + } + } + } +} + +pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { + let path = path.unwrap_or("/tmp/wordcraft-screenshot.png"); + let [w, h] = image.size; + let bytes: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes }; + let png = rendered.to_png(); + #[cfg(not(target_arch = "wasm32"))] + { + if let Err(e) = std::fs::write(path, &png) { + return json!({"ok": false, "error": format!("{path}: {e}")}); + } + } + json!({"ok": true, "result": {"path": path, "width": w, "height": h}}) +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 000000000..2b65f331a --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,600 @@ +//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, +//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About. Every dialog ends by +//! running a command, so agents get the same result without the dialog. + +use egui::{Sense, Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::WordApp; +use crate::theme::{Tokens, semibold}; + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "dialog", rename_all = "camelCase")] +pub enum Dialog { + Font { font: String, size: String, bold: bool, italic: bool, underline: bool, strike: bool, sup: bool, sub: bool, small_caps: bool, caps: bool, hidden: bool, color: String, spacing: f32 }, + Paragraph { align: String, left: f32, right: f32, first: f32, before: f32, after: f32, line: f32, keep_next: bool, keep_lines: bool, page_break: bool, widow: bool }, + Find { query: String, replace: String, match_case: bool, whole_word: bool, regex: bool, replace_mode: bool, message: String }, + Goto { page: String }, + InsertTable { rows: u32, cols: u32 }, + PageSetup { top: f32, bottom: f32, left: f32, right: f32, landscape: bool }, + Link { url: String, text: String }, + Bookmark { name: String }, + WordCount { stats: Value }, + Zoom { percent: f32 }, + Watermark { text: String, diagonal: bool }, + NewStyle { name: String, based_on: String }, + ModifyStyle { id: String, name: String, font: String, size: f32, bold: bool, italic: bool, color: String, before: f32, after: f32 }, + Commands { query: String }, + About, +} + +impl Dialog { + pub fn name(&self) -> &'static str { + match self { + Dialog::Font { .. } => "font", + Dialog::Paragraph { .. } => "paragraph", + Dialog::Find { replace_mode: false, .. } => "find", + Dialog::Find { .. } => "replace", + Dialog::Goto { .. } => "goto", + Dialog::InsertTable { .. } => "insertTable", + Dialog::PageSetup { .. } => "pageSetup", + Dialog::Link { .. } => "link", + Dialog::Bookmark { .. } => "bookmark", + Dialog::WordCount { .. } => "wordCount", + Dialog::Zoom { .. } => "zoom", + Dialog::Watermark { .. } => "watermark", + Dialog::NewStyle { .. } => "newStyle", + Dialog::ModifyStyle { .. } => "modifyStyle", + Dialog::Commands { .. } => "commands", + Dialog::About => "about", + } + } + + pub fn open(name: &str, app: &mut WordApp) -> Option { + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + let s = |k: &str| st.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + Some(match name { + "font" => Dialog::Font { + font: s("font"), + size: st.get("size").and_then(Value::as_f64).map(|v| v.to_string()).unwrap_or_default(), + bold: b("bold"), + italic: b("italic"), + underline: b("underline"), + strike: b("strike"), + sup: b("superscript"), + sub: b("subscript"), + small_caps: false, + caps: false, + hidden: false, + color: s("color"), + spacing: 0.0, + }, + "paragraph" => { + let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); + let rp = rp?; + let line = match rp.line_spacing { + wordcraft_doc::props::LineSpacing::Multiple(m) => m, + _ => 1.0, + }; + Dialog::Paragraph { + align: format!("{:?}", rp.align).to_lowercase(), + left: rp.indent_left / 72.0, + right: rp.indent_right / 72.0, + first: rp.indent_first / 72.0, + before: rp.space_before, + after: rp.space_after, + line, + keep_next: rp.keep_next, + keep_lines: rp.keep_lines, + page_break: rp.page_break_before, + widow: rp.widow_control, + } + } + "find" | "replace" => Dialog::Find { + query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() }, + replace: app.session.find.replace.clone(), + match_case: app.session.find.match_case, + whole_word: app.session.find.whole_word, + regex: app.session.find.regex, + replace_mode: name == "replace", + message: String::new(), + }, + "goto" => Dialog::Goto { page: String::new() }, + "insertTable" => Dialog::InsertTable { rows: 2, cols: 5 }, + "pageSetup" => { + let sp = wordcraft_engine::cmd::page::sect(&app.session); + Dialog::PageSetup { top: sp.margin_top / 72.0, bottom: sp.margin_bottom / 72.0, left: sp.margin_left / 72.0, right: sp.margin_right / 72.0, landscape: sp.landscape } + } + "link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() }, + "bookmark" => Dialog::Bookmark { name: String::new() }, + "wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() }, + "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, + "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, + "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() }, + "commands" => Dialog::Commands { query: String::new() }, + "about" => Dialog::About, + _ => return None, + }) + } + + pub fn modify_style(app: &WordApp, id: &str) -> Option { + let st = app.session.doc.styles.get(id)?; + let rc = app.session.doc.styles.resolve_char(if st.kind == wordcraft_doc::StyleKind::Paragraph { Some(id) } else { None }, &wordcraft_doc::CharProps { style: if st.kind == wordcraft_doc::StyleKind::Character { Some(id.into()) } else { None }, ..Default::default() }); + let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() }); + Some(Dialog::ModifyStyle { + id: id.into(), + name: st.name.clone(), + font: rc.font, + size: rc.size, + bold: rc.bold, + italic: rc.italic, + color: match rc.color { + wordcraft_doc::TextColor::Rgb(c) => c.hex(), + wordcraft_doc::TextColor::Auto => "auto".into(), + }, + before: rp.space_before, + after: rp.space_after, + }) + } +} + +/// Grid picker for Insert › Table (hover to size, click to insert). +pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { + let t = Tokens::get(ui.ctx()); + let id = egui::Id::new("table_picker_hover"); + let hover: (usize, usize) = ui.data(|d| d.get_temp(id)).unwrap_or((0, 0)); + ui.label(if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".to_string() }); + let mut new_hover = (0, 0); + let mut clicked = None; + egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| { + for r in 1..=8 { + for c in 1..=10 { + let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); + let on = r <= hover.0 && c <= hover.1; + ui.painter().rect(rect, 1.0, if on { t.checked } else { t.input }, egui::Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside); + if resp.hovered() { + new_hover = (r, c); + } + if resp.clicked() { + clicked = Some((r, c)); + } + } + ui.end_row(); + } + }); + ui.data_mut(|d| d.insert_temp(id, new_hover)); + if let Some((r, c)) = clicked { + let _ = app.run("insert.table", json!({"rows": r, "cols": c})); + ui.close(); + } +} + +pub fn show(app: &mut WordApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let mut open = true; + let mut close = false; + let title = match &d { + Dialog::Font { .. } => "Font", + Dialog::Paragraph { .. } => "Paragraph", + Dialog::Find { replace_mode: false, .. } => "Find", + Dialog::Find { .. } => "Find and Replace", + Dialog::Goto { .. } => "Go To", + Dialog::InsertTable { .. } => "Insert Table", + Dialog::PageSetup { .. } => "Page Setup", + Dialog::Link { .. } => "Insert Hyperlink", + Dialog::Bookmark { .. } => "Bookmark", + Dialog::WordCount { .. } => "Word Count", + Dialog::Zoom { .. } => "Zoom", + Dialog::Watermark { .. } => "Custom Watermark", + Dialog::NewStyle { .. } => "Create New Style", + Dialog::ModifyStyle { .. } => "Modify Style", + Dialog::Commands { .. } => "Search Commands", + Dialog::About => "About WordCraft", + }; + egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)).open(&mut open).show(ctx, |ui| { + ui.set_min_width(340.0); + close = body(app, ui, &mut d); + }); + if open && !close { + app.dialog = Some(d); + } else { + app.canvas.want_focus = true; + } +} + +fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { + let mut r = (false, false); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + r.1 = true; + } + let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)); + if okb.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + r.0 = true; + } + }); + }); + r +} + +/// Returns true to close. +fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { + match d { + Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing } => { + egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Font:"); + ui.text_edit_singleline(font); + ui.end_row(); + ui.label("Size:"); + ui.text_edit_singleline(size); + ui.end_row(); + ui.label("Font color:"); + ui.text_edit_singleline(color); + ui.end_row(); + ui.label("Spacing (pt):"); + ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)); + ui.end_row(); + }); + ui.separator(); + ui.label(egui::RichText::new("Effects").font(semibold(12.5))); + egui::Grid::new("fx").num_columns(2).show(ui, |ui| { + ui.checkbox(bold, "Bold"); + ui.checkbox(italic, "Italic"); + ui.end_row(); + ui.checkbox(underline, "Underline"); + ui.checkbox(strike, "Strikethrough"); + ui.end_row(); + ui.checkbox(sup, "Superscript"); + ui.checkbox(sub, "Subscript"); + ui.end_row(); + ui.checkbox(small_caps, "Small caps"); + ui.checkbox(caps, "All caps"); + ui.end_row(); + ui.checkbox(hidden, "Hidden"); + ui.end_row(); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let mut props = json!({ + "bold": *bold, "italic": *italic, "strike": *strike, + "underline": if *underline { "single" } else { "none" }, + "vertAlign": if *sup { "superscript" } else if *sub { "subscript" } else { "baseline" }, + "smallCaps": *small_caps, "caps": *caps, "hidden": *hidden, "spacing": *spacing, + }); + if !font.trim().is_empty() { + props["font"] = json!(font.trim()); + } + if let Ok(s) = size.trim().parse::() { + props["size"] = json!(s); + } + if let Some(c) = wordcraft_doc::Rgb::parse(color) { + props["color"] = json!({"Rgb": [c.0, c.1, c.2]}); + } + let _ = app.run("format.set", json!({"props": props})); + } + ok || cancel + } + Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => { + ui.label(egui::RichText::new("General").font(semibold(12.5))); + egui::ComboBox::from_label("Alignment").selected_text(align.clone()).show_ui(ui, |ui| { + for a in ["left", "center", "right", "justify"] { + ui.selectable_value(align, a.to_string(), a); + } + }); + ui.label(egui::RichText::new("Indentation").font(semibold(12.5))); + egui::Grid::new("ind").num_columns(4).show(ui, |ui| { + ui.label("Left:"); + ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2)); + ui.label("Right:"); + ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2)); + ui.end_row(); + ui.label("First line:"); + ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2)); + ui.end_row(); + }); + ui.label(egui::RichText::new("Spacing").font(semibold(12.5))); + egui::Grid::new("sp").num_columns(4).show(ui, |ui| { + ui.label("Before:"); + ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt")); + ui.label("Line spacing:"); + ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0)); + ui.end_row(); + ui.label("After:"); + ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt")); + ui.end_row(); + }); + ui.label(egui::RichText::new("Line and Page Breaks").font(semibold(12.5))); + ui.checkbox(widow, "Widow/Orphan control"); + ui.checkbox(keep_next, "Keep with next"); + ui.checkbox(keep_lines, "Keep lines together"); + ui.checkbox(page_break, "Page break before"); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("para.align", json!({"value": align})); + let _ = app.run("para.set", json!({"props": { + "indentLeft": *left * 72.0, "indentRight": *right * 72.0, "indentFirst": *first * 72.0, + "spaceBefore": *before, "spaceAfter": *after, "lineSpacing": {"rule": "multiple", "value": *line}, + "keepNext": *keep_next, "keepLines": *keep_lines, "pageBreakBefore": *page_break, "widowControl": *widow, + }})); + } + ok || cancel + } + Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => { + ui.horizontal(|ui| { + if ui.selectable_label(!*replace_mode, "Find").clicked() { + *replace_mode = false; + } + if ui.selectable_label(*replace_mode, "Replace").clicked() { + *replace_mode = true; + } + }); + ui.horizontal(|ui| { + ui.label("Find what:"); + ui.add(egui::TextEdit::singleline(query).desired_width(240.0)); + }); + if *replace_mode { + ui.horizontal(|ui| { + ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)); + }); + } + ui.horizontal(|ui| { + ui.checkbox(match_case, "Match case"); + ui.checkbox(whole_word, "Whole words"); + ui.checkbox(regex, "Wildcards (regex)"); + }); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).weak()); + } + let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex}); + let mut close = false; + ui.horizontal(|ui| { + if *replace_mode { + if ui.button("Replace All").clicked() { + match app.run("edit.replaceAll", opts.clone()) { + Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]), + Err(e) => *message = e, + } + } + if ui.button("Replace").clicked() { + let _ = app.run("edit.replace", opts.clone()); + } + } + if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let _ = app.session.run("edit.find", &opts); + match app.run("edit.findNext", json!({})) { + Ok(r) => *message = format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["count"]), + Err(e) => *message = e, + } + } + if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + }); + close + } + Dialog::Goto { page } => { + ui.horizontal(|ui| { + ui.label("Enter page number:"); + ui.text_edit_singleline(page); + }); + let (ok, cancel) = buttons(ui, "Go To"); + if ok && let Ok(n) = page.trim().parse::() { + let _ = app.run("edit.goto", json!({"page": n})); + } + ok || cancel + } + Dialog::InsertTable { rows, cols } => { + egui::Grid::new("it").num_columns(2).show(ui, |ui| { + ui.label("Number of columns:"); + ui.add(egui::DragValue::new(cols).range(1..=63)); + ui.end_row(); + ui.label("Number of rows:"); + ui.add(egui::DragValue::new(rows).range(1..=1000)); + ui.end_row(); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("insert.table", json!({"rows": *rows, "cols": *cols})); + } + ok || cancel + } + Dialog::PageSetup { top, bottom, left, right, landscape } => { + ui.label(egui::RichText::new("Margins").font(semibold(12.5))); + egui::Grid::new("ps").num_columns(4).show(ui, |ui| { + ui.label("Top:"); + ui.add(egui::DragValue::new(top).speed(0.05).suffix("\"").max_decimals(2)); + ui.label("Bottom:"); + ui.add(egui::DragValue::new(bottom).speed(0.05).suffix("\"").max_decimals(2)); + ui.end_row(); + ui.label("Left:"); + ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2)); + ui.label("Right:"); + ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2)); + ui.end_row(); + }); + ui.label(egui::RichText::new("Orientation").font(semibold(12.5))); + ui.horizontal(|ui| { + ui.radio_value(landscape, false, "Portrait"); + ui.radio_value(landscape, true, "Landscape"); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }})); + let _ = app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0})); + } + ok || cancel + } + Dialog::Link { url, text } => { + egui::Grid::new("lnk").num_columns(2).show(ui, |ui| { + ui.label("Text to display:"); + ui.text_edit_singleline(text); + ui.end_row(); + ui.label("Address:"); + ui.text_edit_singleline(url); + ui.end_row(); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("insert.link", json!({"url": url, "text": text})); + } + ok || cancel + } + Dialog::Bookmark { name } => { + ui.label("Bookmark name:"); + ui.text_edit_singleline(name); + let marks = app.session.doc.bookmarks(); + for (n, _) in &marks { + if ui.selectable_label(false, n).clicked() { + let _ = app.run("edit.goto", json!({"bookmark": n})); + } + } + let (ok, cancel) = buttons(ui, "Add"); + if ok { + let _ = app.run("insert.bookmark", json!({"name": name})); + } + ok || cancel + } + Dialog::WordCount { stats } => { + egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| { + for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Characters (no spaces)", "characters"), ("Characters (with spaces)", "charactersWithSpaces"), ("Paragraphs", "paragraphs"), ("Lines", "lines")] { + ui.label(l); + ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default()); + ui.end_row(); + } + }); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + Dialog::Zoom { percent } => { + ui.horizontal(|ui| { + for p in [200.0, 100.0, 75.0] { + ui.radio_value(percent, p, format!("{p}%")); + } + }); + ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%")); + let mut fit = None; + ui.horizontal(|ui| { + if ui.button("Page width").clicked() { + fit = Some("pageWidth"); + } + if ui.button("Whole page").clicked() { + fit = Some("onePage"); + } + if ui.button("Many pages").clicked() { + fit = Some("multiplePages"); + } + }); + if let Some(f) = fit { + let _ = app.run("view.zoom", json!({"value": f})); + return true; + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("view.zoom", json!({"value": *percent})); + } + ok || cancel + } + Dialog::Watermark { text, diagonal } => { + ui.horizontal(|ui| { + ui.label("Text:"); + ui.text_edit_singleline(text); + }); + ui.horizontal(|ui| { + ui.radio_value(diagonal, true, "Diagonal"); + ui.radio_value(diagonal, false, "Horizontal"); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("design.watermark", json!({"text": text, "diagonal": *diagonal})); + } + ok || cancel + } + Dialog::NewStyle { name, based_on } => { + egui::Grid::new("ns").num_columns(2).show(ui, |ui| { + ui.label("Name:"); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label("Style based on:"); + ui.text_edit_singleline(based_on); + ui.end_row(); + }); + ui.label(egui::RichText::new("The new style takes the formatting of the current paragraph.").small().weak()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on})); + } + ok || cancel + } + Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => { + egui::Grid::new("ms").num_columns(2).show(ui, |ui| { + ui.label("Name:"); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label("Font:"); + ui.text_edit_singleline(font); + ui.end_row(); + ui.label("Size:"); + ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)); + ui.end_row(); + ui.label("Color:"); + ui.text_edit_singleline(color); + ui.end_row(); + ui.label("Space before/after:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(before).range(0.0..=1584.0)); + ui.add(egui::DragValue::new(after).range(0.0..=1584.0)); + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + ui.checkbox(bold, "Bold"); + ui.checkbox(italic, "Italic"); + }); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let mut chr = json!({"font": font, "size": *size, "bold": *bold, "italic": *italic}); + if let Some(c) = wordcraft_doc::Rgb::parse(color) { + chr["color"] = json!({"Rgb": [c.0, c.1, c.2]}); + } + let _ = app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}})); + } + ok || cancel + } + Dialog::Commands { query } => { + let r = ui.add(egui::TextEdit::singleline(query).hint_text("Type a command, e.g. \"insert table\"").desired_width(380.0)); + r.request_focus(); + let q = query.to_lowercase(); + let reg = app.session.registry.clone(); + let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg.all().iter().filter(|c| !q.is_empty() && (c.label.to_lowercase().contains(&q) || c.id.to_lowercase().contains(&q) || c.location.to_lowercase().contains(&q))).collect(); + hits.truncate(14); + let mut close = false; + for c in hits { + let sc = crate::widgets::shortcut_text(app, c.id); + let label = format!("{} — {}", c.label, c.location); + if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() { + let _ = app.run(c.id, json!({})); + close = true; + } + } + close || ui.input(|i| i.key_pressed(egui::Key::Escape)) + } + Dialog::About => { + ui.label(egui::RichText::new("WordCraft").font(semibold(22.0))); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.label("A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team."); + ui.add_space(6.0); + ui.hyperlink_to("getartcraft.com/apps/wordcraft", "https://getartcraft.com/apps/wordcraft"); + ui.hyperlink_to("Join us on Discord: discord.gg/artcraft", "https://discord.gg/artcraft"); + ui.hyperlink_to("Source code: github.com/storytold/wordcraft", "https://github.com/storytold/wordcraft"); + ui.add_space(6.0); + ui.label(egui::RichText::new("MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.").small().weak()); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 000000000..8a358c528 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,1003 @@ +//! WordCraft's icon set, drawn in code (original artwork; no external icon assets). +//! +//! Icons are designed on a 20×20 grid in two colours: line work (`c`) and an accent (`a`). + +use egui::{Color32, FontId, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; + +struct Pen<'a> { + p: &'a Painter, + r: Rect, + c: Color32, + a: Color32, + w: f32, +} + +impl Pen<'_> { + fn s(&self) -> f32 { + self.r.width().min(self.r.height()) / 20.0 + } + fn pt(&self, x: f32, y: f32) -> Pos2 { + let s = self.s(); + let o = self.r.center() - vec2(10.0 * s, 10.0 * s); + pos2(o.x + x * s, o.y + y * s) + } + fn stroke(&self, c: Color32) -> Stroke { + Stroke::new(self.w * self.s(), c) + } + fn line(&self, pts: &[(f32, f32)]) { + self.line_c(pts, self.c); + } + fn line_c(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect(); + self.p.add(Shape::line(v, self.stroke(c))); + } + fn closed(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect(); + self.p.add(Shape::closed_line(v, self.stroke(c))); + } + fn fill(&self, pts: &[(f32, f32)], c: Color32) { + let v: Vec = pts.iter().map(|&(x, y)| self.pt(x, y)).collect(); + self.p.add(Shape::convex_polygon(v, c, Stroke::NONE)); + } + fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, c: Color32) { + self.p.rect_stroke(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), 1.0, self.stroke(c), egui::StrokeKind::Middle); + } + fn frect(&self, x0: f32, y0: f32, x1: f32, y1: f32, c: Color32) { + self.p.rect_filled(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), 0.5, c); + } + fn circle(&self, x: f32, y: f32, r: f32, c: Color32) { + self.p.circle_stroke(self.pt(x, y), r * self.s(), self.stroke(c)); + } + fn fcircle(&self, x: f32, y: f32, r: f32, c: Color32) { + self.p.circle_filled(self.pt(x, y), r * self.s(), c); + } + fn text(&self, x: f32, y: f32, size: f32, t: &str, c: Color32, bold: bool) { + let font = if bold { crate::theme::semibold(size * self.s()) } else { FontId::proportional(size * self.s()) }; + self.p.text(self.pt(x, y), egui::Align2::CENTER_CENTER, t, font, c); + } + /// Horizontal text lines (a paragraph). + fn lines(&self, x0: f32, x1: f32, ys: &[f32]) { + for y in ys { + self.line(&[(x0, *y), (x1, *y)]); + } + } + /// A page outline with a folded corner. + fn page(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.closed(&[(x0, y0), (x1 - 4.0, y0), (x1, y0 + 4.0), (x1, y1), (x0, y1)], self.c); + self.line(&[(x1 - 4.0, y0), (x1 - 4.0, y0 + 4.0), (x1, y0 + 4.0)]); + } + fn arrow_down(&self, x: f32, y: f32, c: Color32) { + self.fill(&[(x - 2.5, y - 1.2), (x + 2.5, y - 1.2), (x, y + 1.5)], c); + } +} + +/// Paint icon `name` into `r`. `c` is the line colour; the accent comes from the theme. +pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { + let pen = Pen { p, r, c, a: accent, w: 1.25 }; + let a = accent; + let red = Color32::from_rgb(0xD1, 0x3B, 0x3B); + let yellow = Color32::from_rgb(0xF2, 0xC8, 0x11); + let green = Color32::from_rgb(0x2E, 0x8B, 0x57); + let orange = Color32::from_rgb(0xE0, 0x7B, 0x1F); + match name { + // Clipboard + "paste" => { + pen.rect(4.0, 3.5, 14.0, 17.0, c); + pen.frect(7.0, 2.0, 11.0, 5.0, a); + pen.frect(9.0, 8.0, 17.5, 18.5, pen.a.linear_multiply(0.18)); + pen.rect(9.0, 8.0, 17.5, 18.5, a); + pen.lines(10.8, 15.8, &[11.0, 13.5, 16.0]); + } + "cut" => { + pen.circle(6.0, 15.0, 2.5, c); + pen.circle(14.0, 15.0, 2.5, c); + pen.line(&[(7.5, 13.0), (14.0, 3.0)]); + pen.line(&[(12.5, 13.0), (6.0, 3.0)]); + } + "copy" => { + pen.rect(3.5, 3.0, 12.5, 14.0, c); + pen.frect(7.5, 6.5, 16.5, 17.5, Color32::TRANSPARENT); + pen.rect(7.5, 6.5, 16.5, 17.5, a); + } + "painter" => { + pen.frect(4.0, 3.0, 16.0, 8.0, yellow); + pen.rect(4.0, 3.0, 16.0, 8.0, c); + pen.line(&[(16.0, 5.5), (17.5, 5.5), (17.5, 10.0), (10.0, 10.0), (10.0, 12.0)]); + pen.frect(8.8, 12.0, 11.2, 18.0, c); + } + // Font + "bold" => pen.text(10.0, 10.5, 15.0, "B", c, true), + "italic" => pen.text(10.0, 10.5, 15.0, "I", c, false), + "underline" => { + pen.text(10.0, 9.0, 14.0, "U", c, false); + pen.line(&[(5.0, 17.0), (15.0, 17.0)]); + } + "strike" => { + pen.text(10.0, 10.0, 12.0, "ab", c, false); + pen.line_c(&[(3.0, 10.5), (17.0, 10.5)], c); + } + "subscript" => { + pen.text(8.0, 9.0, 13.0, "x", c, false); + pen.text(15.0, 14.5, 8.0, "2", a, true); + } + "superscript" => { + pen.text(8.0, 11.0, 13.0, "x", c, false); + pen.text(15.0, 5.0, 8.0, "2", a, true); + } + "grow" => { + pen.text(8.0, 11.0, 15.0, "A", c, false); + pen.fill(&[(14.5, 6.0), (18.5, 6.0), (16.5, 3.0)], a); + } + "shrink" => { + pen.text(8.0, 12.0, 11.0, "A", c, false); + pen.fill(&[(14.5, 4.0), (18.5, 4.0), (16.5, 7.0)], a); + } + "case" => pen.text(10.0, 10.5, 12.0, "Aa", c, false), + "clear" => { + pen.text(8.0, 9.0, 13.0, "A", c, false); + pen.fill(&[(10.0, 15.0), (14.0, 11.0), (18.0, 15.0), (14.0, 19.0)], Color32::from_rgb(0xE8, 0x6F, 0xA0)); + } + "highlight" => { + pen.fill(&[(5.0, 12.0), (11.0, 4.0), (14.0, 7.0), (8.0, 15.0)], c); + pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]); + pen.frect(2.0, 16.5, 18.0, 19.0, yellow); + } + "fontcolor" => { + pen.text(10.0, 9.0, 14.0, "A", c, false); + pen.frect(2.0, 16.5, 18.0, 19.0, red); + } + "effects" => { + pen.text(10.0, 10.0, 15.0, "A", a, true); + pen.text(10.5, 10.5, 15.0, "A", Color32::from_rgba_unmultiplied(0, 0, 0, 40), true); + } + "fontdialog" => pen.line(&[(13.0, 13.0), (17.0, 17.0), (17.0, 13.0)]), + // Paragraph + "bullets" => { + for (i, y) in [5.0, 10.0, 15.0].iter().enumerate() { + pen.fcircle(4.0, *y, 1.4, if i == 0 { a } else { c }); + pen.line(&[(7.5, *y), (17.0, *y)]); + } + } + "numbering" => { + for (i, y) in [5.0, 10.0, 15.0].iter().enumerate() { + pen.text(4.0, *y, 6.0, &(i + 1).to_string(), if i == 0 { a } else { c }, true); + pen.line(&[(7.5, *y), (17.0, *y)]); + } + } + "multilevel" => { + pen.text(3.5, 4.5, 5.5, "1", a, true); + pen.line(&[(6.5, 4.5), (17.0, 4.5)]); + pen.text(6.5, 10.0, 5.5, "a", c, true); + pen.line(&[(9.5, 10.0), (17.0, 10.0)]); + pen.text(9.5, 15.5, 5.5, "i", c, true); + pen.line(&[(12.0, 15.5), (17.0, 15.5)]); + } + "outdent" | "indent" => { + pen.lines(9.0, 17.0, &[7.0, 10.0, 13.0]); + pen.lines(3.0, 17.0, &[3.5, 16.5]); + if name == "indent" { + pen.fill(&[(3.0, 7.0), (3.0, 13.0), (6.5, 10.0)], a); + } else { + pen.fill(&[(6.5, 7.0), (6.5, 13.0), (3.0, 10.0)], a); + } + } + "sort" => { + pen.text(6.0, 5.5, 7.0, "A", c, true); + pen.text(6.0, 14.5, 7.0, "Z", c, true); + pen.line_c(&[(14.0, 3.0), (14.0, 17.0)], a); + pen.line_c(&[(11.5, 14.5), (14.0, 17.0), (16.5, 14.5)], a); + } + "pilcrow" => pen.text(10.0, 10.5, 15.0, "¶", c, false), + "alignLeft" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 3.0, 12.0, &[7.0, 13.0]), + "alignCenter" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 6.0, 14.0, &[7.0, 13.0]), + "alignRight" => pen.lines(3.0, 17.0, &[4.0, 10.0, 16.0]).then_lines(&pen, 8.0, 17.0, &[7.0, 13.0]), + "justify" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]), + "lineSpacing" => { + pen.lines(9.0, 17.0, &[5.0, 10.0, 15.0]); + pen.line_c(&[(4.5, 3.0), (4.5, 17.0)], a); + pen.line_c(&[(2.5, 5.0), (4.5, 3.0), (6.5, 5.0)], a); + pen.line_c(&[(2.5, 15.0), (4.5, 17.0), (6.5, 15.0)], a); + } + "shading" => { + pen.fill(&[(4.0, 9.0), (10.0, 3.0), (16.0, 9.0), (10.0, 15.0)], Color32::TRANSPARENT); + pen.closed(&[(4.0, 9.0), (10.0, 3.0), (16.0, 9.0), (10.0, 15.0)], c); + pen.fcircle(16.5, 13.5, 1.6, a); + pen.frect(2.0, 16.5, 18.0, 19.0, yellow); + } + "borders" => { + for y in [3.0, 10.0, 17.0] { + for x in [3.0, 6.5, 10.0, 13.5, 17.0] { + pen.fcircle(x, y, 0.6, c); + } + } + for x in [3.0, 10.0, 17.0] { + for y in [6.5, 13.5] { + pen.fcircle(x, y, 0.6, c); + } + } + pen.line_c(&[(3.0, 17.0), (17.0, 17.0)], a); + } + // Editing + "find" => { + pen.circle(8.0, 8.0, 5.0, c); + pen.line_c(&[(11.5, 11.5), (17.0, 17.0)], a); + } + "replace" => { + pen.text(6.0, 6.0, 8.0, "ab", c, false); + pen.text(13.0, 15.0, 8.0, "ac", a, false); + pen.line(&[(13.0, 3.5), (16.0, 3.5), (16.0, 8.5)]); + pen.line(&[(14.5, 7.0), (16.0, 8.5), (17.5, 7.0)]); + } + "select" => { + pen.fill(&[(5.0, 3.0), (5.0, 16.0), (8.0, 13.0), (10.5, 18.0), (12.5, 17.0), (10.0, 12.0), (14.0, 12.0)], c); + } + "dictate" => { + pen.rect(7.5, 2.5, 12.5, 12.0, c); + pen.line(&[(5.0, 9.5), (5.0, 11.0), (10.0, 15.0), (15.0, 11.0), (15.0, 9.5)]); + pen.line(&[(10.0, 15.0), (10.0, 18.0)]); + } + "editor" | "spelling" => { + pen.text(8.0, 7.0, 9.0, "abc", c, true); + pen.line_c(&[(5.0, 13.0), (8.5, 16.5), (16.0, 9.0)], green); + } + "styles" => { + pen.text(10.0, 10.0, 14.0, "A", a, false); + pen.line(&[(13.0, 15.0), (17.0, 11.0)]); + } + "stylesPane" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.line(&[(11.0, 3.0), (11.0, 17.0)]); + pen.text(7.0, 10.0, 8.0, "A", a, false); + } + // Insert + "coverPage" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(5.5, 7.0, 14.5, 10.0, a); + } + "blankPage" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.line_c(&[(10.0, 7.0), (10.0, 13.0)], a); + pen.line_c(&[(7.0, 10.0), (13.0, 10.0)], a); + } + "pageBreak" => { + pen.line(&[(4.0, 2.0), (4.0, 7.0), (16.0, 7.0), (16.0, 2.0)]); + pen.line(&[(4.0, 18.0), (4.0, 13.0), (16.0, 13.0), (16.0, 18.0)]); + pen.line_c(&[(2.0, 10.0), (18.0, 10.0)], a); + } + "table" => { + pen.frect(3.0, 3.0, 17.0, 7.0, a); + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.line(&[(3.0, 7.0), (17.0, 7.0)]); + pen.line(&[(3.0, 12.0), (17.0, 12.0)]); + pen.line(&[(8.0, 3.0), (8.0, 17.0)]); + pen.line(&[(12.5, 3.0), (12.5, 17.0)]); + } + "picture" => { + pen.rect(2.5, 4.0, 17.5, 16.0, c); + pen.fill(&[(4.0, 14.5), (8.5, 8.0), (12.0, 14.5)], a); + pen.fill(&[(9.5, 14.5), (13.0, 10.0), (16.0, 14.5)], green); + pen.fcircle(13.5, 7.0, 1.5, yellow); + } + "onlinePicture" => { + paint(p, Rect::from_min_size(r.min, r.size() * 0.8), "picture", c, accent); + pen.circle(15.0, 15.0, 3.5, a); + } + "shapes" => { + pen.rect(3.0, 3.0, 11.0, 11.0, c); + pen.fcircle(13.0, 13.0, 4.5, a); + } + "icons" => { + pen.circle(10.0, 10.0, 7.5, c); + pen.fcircle(7.5, 8.0, 1.0, c); + pen.fcircle(12.5, 8.0, 1.0, c); + pen.line_c(&[(6.5, 12.0), (10.0, 14.5), (13.5, 12.0)], a); + } + "models3d" => { + pen.closed(&[(10.0, 2.5), (17.0, 6.0), (17.0, 14.0), (10.0, 17.5), (3.0, 14.0), (3.0, 6.0)], c); + pen.line_c(&[(3.0, 6.0), (10.0, 9.5), (17.0, 6.0)], a); + pen.line_c(&[(10.0, 9.5), (10.0, 17.5)], a); + } + "smartArt" => { + pen.frect(7.0, 2.5, 13.0, 6.5, a); + pen.rect(2.5, 13.0, 8.0, 17.0, c); + pen.rect(12.0, 13.0, 17.5, 17.0, c); + pen.line(&[(10.0, 6.5), (10.0, 10.0), (5.0, 10.0), (5.0, 13.0)]); + pen.line(&[(10.0, 10.0), (15.0, 10.0), (15.0, 13.0)]); + } + "chart" => { + pen.frect(3.5, 11.0, 6.5, 17.0, a); + pen.frect(8.5, 6.0, 11.5, 17.0, orange); + pen.frect(13.5, 9.0, 16.5, 17.0, green); + pen.line(&[(2.0, 17.5), (18.0, 17.5)]); + } + "screenshot" => { + pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]); + pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]); + pen.line(&[(17.0, 13.0), (17.0, 17.0), (13.0, 17.0)]); + pen.line(&[(7.0, 17.0), (3.0, 17.0), (3.0, 13.0)]); + pen.fcircle(10.0, 10.0, 3.0, a); + } + "video" => { + pen.rect(2.5, 5.0, 14.0, 15.0, c); + pen.fill(&[(14.0, 10.0), (18.0, 6.5), (18.0, 13.5)], a); + } + "link" => { + pen.closed(&[(3.0, 10.0), (7.0, 6.0), (9.5, 6.0), (9.5, 8.0), (7.5, 8.0), (5.0, 10.5), (7.5, 13.0), (9.5, 13.0), (9.5, 15.0), (7.0, 15.0)], c); + pen.line_c(&[(7.5, 10.5), (12.5, 10.5)], a); + pen.closed(&[(17.0, 10.5), (13.0, 14.5), (10.5, 14.5), (10.5, 12.5), (12.5, 12.5), (15.0, 10.0), (12.5, 7.5), (10.5, 7.5), (10.5, 5.5), (13.0, 5.5)], c); + } + "bookmark" => { + pen.closed(&[(5.5, 2.5), (14.5, 2.5), (14.5, 17.5), (10.0, 13.5), (5.5, 17.5)], c); + pen.fill(&[(7.0, 4.0), (13.0, 4.0), (13.0, 8.0), (7.0, 8.0)], a); + } + "crossRef" => { + pen.page(3.0, 2.0, 12.0, 14.0); + pen.line_c(&[(9.0, 12.0), (17.0, 17.0)], a); + pen.line_c(&[(14.0, 17.5), (17.0, 17.0), (16.5, 14.0)], a); + } + "comment" | "newComment" => { + pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c); + if name == "newComment" { + pen.line_c(&[(10.0, 6.0), (10.0, 11.0)], a); + pen.line_c(&[(7.5, 8.5), (12.5, 8.5)], a); + } else { + pen.lines(5.5, 14.5, &[7.0, 10.0]); + } + } + "header" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(5.5, 5.0, 14.5, 7.5, a); + pen.lines(6.0, 14.0, &[11.0, 13.5]); + } + "footer" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.lines(6.0, 14.0, &[6.5, 9.0]); + pen.frect(5.5, 13.0, 14.5, 15.5, a); + } + "pageNumber" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.text(10.0, 13.0, 7.0, "#", a, true); + } + "textBox" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.text(10.0, 10.0, 10.0, "A", a, false); + } + "quickParts" => { + pen.rect(3.0, 3.0, 9.0, 9.0, c); + pen.frect(11.0, 3.0, 17.0, 9.0, a); + pen.rect(3.0, 11.0, 9.0, 17.0, c); + pen.rect(11.0, 11.0, 17.0, 17.0, c); + } + "wordArt" => { + pen.text(10.0, 10.5, 15.0, "A", a, true); + pen.line_c(&[(3.0, 17.5), (17.0, 17.5)], orange); + } + "dropCap" => { + pen.text(6.0, 8.0, 12.0, "A", a, true); + pen.lines(11.0, 17.0, &[4.0, 8.0, 12.0]); + pen.lines(3.0, 17.0, &[16.0]); + } + "signature" => { + pen.line_c(&[(3.0, 13.0), (6.0, 7.0), (8.0, 12.0), (11.0, 6.0), (13.0, 12.0)], a); + pen.line(&[(3.0, 16.5), (17.0, 16.5)]); + } + "dateTime" => { + pen.rect(3.0, 4.0, 17.0, 17.0, c); + pen.frect(3.0, 4.0, 17.0, 7.5, a); + pen.lines(5.0, 15.0, &[11.0, 14.0]); + } + "object" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.fcircle(10.0, 10.0, 3.5, a); + } + "equation" => pen.text(10.0, 10.0, 17.0, "π", a, false), + "symbol" => pen.text(10.0, 10.0, 17.0, "Ω", c, false), + // Draw + "pen" => { + pen.line(&[(4.0, 16.0), (14.0, 6.0), (16.0, 8.0), (6.0, 18.0), (3.5, 18.5), (4.0, 16.0)]); + pen.fill(&[(14.0, 6.0), (15.5, 4.5), (17.5, 6.5), (16.0, 8.0)], a); + } + "pencil" => { + pen.line(&[(4.0, 16.0), (14.0, 6.0), (16.0, 8.0), (6.0, 18.0), (3.5, 18.5), (4.0, 16.0)]); + pen.fill(&[(14.0, 6.0), (15.5, 4.5), (17.5, 6.5), (16.0, 8.0)], orange); + } + "eraser" => { + pen.closed(&[(3.0, 13.0), (11.0, 5.0), (17.0, 11.0), (11.0, 17.0), (7.0, 17.0)], c); + pen.fill(&[(3.0, 13.0), (7.0, 9.0), (13.0, 15.0), (11.0, 17.0), (7.0, 17.0)], Color32::from_rgb(0xE8, 0x6F, 0xA0)); + } + "lasso" => { + pen.closed(&[(4.0, 7.0), (9.0, 3.0), (16.0, 5.0), (16.0, 11.0), (10.0, 13.0), (4.0, 11.0)], c); + pen.line_c(&[(6.0, 12.5), (5.0, 17.5)], a); + } + "inkToShape" => { + pen.line_c(&[(3.0, 10.0), (5.0, 6.0), (8.0, 12.0), (10.0, 7.0)], a); + pen.rect(11.0, 9.0, 17.0, 15.0, c); + } + "inkToMath" => { + pen.line_c(&[(3.0, 12.0), (5.0, 7.0), (7.0, 12.0)], a); + pen.text(13.5, 10.0, 11.0, "√", c, false); + } + "canvas" => { + pen.rect(2.5, 4.0, 17.5, 16.0, c); + pen.line_c(&[(5.0, 13.0), (9.0, 8.0), (12.0, 11.0), (15.0, 7.0)], a); + } + "replay" => { + pen.circle(10.0, 10.0, 6.5, c); + pen.fill(&[(8.5, 7.0), (8.5, 13.0), (13.0, 10.0)], a); + } + // Design + "themes" => { + pen.frect(3.0, 3.0, 10.0, 10.0, a); + pen.frect(10.0, 3.0, 17.0, 10.0, orange); + pen.frect(3.0, 10.0, 10.0, 17.0, green); + pen.frect(10.0, 10.0, 17.0, 17.0, yellow); + } + "colors" => { + pen.fcircle(7.0, 7.5, 3.5, red); + pen.fcircle(13.0, 7.5, 3.5, a); + pen.fcircle(10.0, 13.0, 3.5, green); + } + "fonts" => pen.text(10.0, 10.0, 13.0, "Aa", a, false), + "paraSpacing" => { + pen.lines(8.0, 17.0, &[4.0, 7.0, 13.0, 16.0]); + pen.line_c(&[(4.0, 8.0), (4.0, 12.0)], a); + pen.line_c(&[(2.5, 9.5), (4.0, 8.0), (5.5, 9.5)], a); + pen.line_c(&[(2.5, 10.5), (4.0, 12.0), (5.5, 10.5)], a); + } + "effectsDesign" => { + pen.fcircle(10.0, 10.0, 6.0, a.linear_multiply(0.5)); + pen.circle(10.0, 10.0, 6.0, c); + } + "setDefault" => { + pen.line_c(&[(4.0, 10.5), (8.5, 15.0), (16.5, 5.0)], green); + } + "watermark" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.text(10.0, 11.0, 7.0, "W", a.linear_multiply(0.6), true); + } + "pageColor" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(5.0, 9.0, 15.0, 17.0, a.linear_multiply(0.5)); + } + "pageBorders" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.rect(6.0, 6.5, 14.0, 15.5, a); + } + // Layout + "margins" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(6.0, 4.5, 14.0, 15.5, a.linear_multiply(0.3)); + } + "orientation" => { + pen.page(3.0, 3.0, 11.0, 15.0); + pen.rect(9.0, 10.0, 18.0, 17.0, a); + } + "size" => { + pen.page(5.0, 2.0, 15.0, 18.0); + pen.line_c(&[(2.5, 2.5), (2.5, 17.5)], a); + pen.line_c(&[(5.0, 19.0), (15.0, 19.0)], a); + } + "columns" => { + pen.lines(3.0, 9.0, &[4.0, 7.0, 10.0, 13.0, 16.0]); + pen.lines(11.0, 17.0, &[4.0, 7.0, 10.0, 13.0]); + } + "breaks" => { + pen.page(4.0, 1.5, 16.0, 8.5); + pen.page(4.0, 11.5, 16.0, 18.5); + pen.line_c(&[(2.0, 10.0), (18.0, 10.0)], a); + } + "lineNumbers" => { + for (i, y) in [4.0, 8.0, 12.0, 16.0].iter().enumerate() { + pen.text(4.0, *y, 5.0, &(i + 1).to_string(), a, true); + pen.line(&[(7.0, *y), (17.0, *y)]); + } + } + "hyphenation" => { + pen.text(7.0, 7.0, 8.0, "ab-", c, false); + pen.text(9.0, 14.5, 8.0, "cd", a, false); + } + "position" | "wrapText" => { + pen.lines(3.0, 17.0, &[3.0, 17.0]); + pen.lines(3.0, 6.0, &[7.0, 10.0, 13.0]); + pen.frect(7.5, 6.0, 12.5, 14.0, a); + pen.lines(14.0, 17.0, &[7.0, 10.0, 13.0]); + } + "bringForward" => { + pen.rect(3.0, 3.0, 12.0, 12.0, c); + pen.frect(8.0, 8.0, 17.0, 17.0, a); + } + "sendBackward" => { + pen.frect(3.0, 3.0, 12.0, 12.0, a); + pen.rect(8.0, 8.0, 17.0, 17.0, c); + } + "selectionPane" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.lines(6.0, 14.0, &[7.0, 10.0, 13.0]); + } + "align" => { + pen.line_c(&[(3.0, 2.0), (3.0, 18.0)], a); + pen.rect(4.5, 4.0, 15.0, 8.0, c); + pen.rect(4.5, 11.0, 11.0, 15.0, c); + } + "group" => { + pen.rect(2.5, 2.5, 17.5, 17.5, a); + pen.rect(5.0, 5.0, 11.0, 11.0, c); + pen.fcircle(13.0, 13.0, 3.0, c); + } + "rotate" => { + pen.circle(10.0, 10.0, 6.0, c); + pen.fill(&[(14.5, 3.0), (17.5, 6.0), (13.5, 7.0)], a); + } + "indentLeft" | "indentRight" | "spaceBefore" | "spaceAfter" => { + pen.lines(3.0, 17.0, &[6.0, 10.0, 14.0]); + pen.fcircle(if name.contains("Right") { 15.0 } else { 5.0 }, 10.0, 1.6, a); + } + // References + "toc" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.lines(6.0, 11.0, &[7.0, 10.0, 13.0]); + for y in [7.0, 10.0, 13.0] { + pen.fcircle(13.5, y, 0.8, a); + } + } + "addText" => { + pen.lines(3.0, 13.0, &[5.0, 9.0, 13.0]); + pen.line_c(&[(15.0, 11.0), (15.0, 17.0)], a); + pen.line_c(&[(12.0, 14.0), (18.0, 14.0)], a); + } + "updateTable" | "update" => { + pen.circle(10.0, 10.0, 6.0, c); + pen.fill(&[(14.5, 3.0), (17.5, 6.0), (13.5, 7.0)], green); + } + "footnote" => { + pen.text(6.5, 7.0, 10.0, "ab", c, false); + pen.text(13.5, 4.5, 6.0, "1", a, true); + pen.line(&[(3.0, 13.0), (9.0, 13.0)]); + pen.lines(3.0, 17.0, &[16.0]); + } + "endnote" => { + pen.text(6.5, 7.0, 10.0, "ab", c, false); + pen.text(13.5, 4.5, 6.0, "i", a, true); + pen.lines(3.0, 17.0, &[13.0, 16.0]); + } + "nextFootnote" => { + pen.text(7.0, 7.0, 8.0, "ab", c, false); + pen.text(13.0, 5.0, 6.0, "1", a, true); + pen.line_c(&[(6.0, 15.0), (14.0, 15.0)], a); + pen.line_c(&[(11.5, 12.5), (14.0, 15.0), (11.5, 17.5)], a); + } + "showNotes" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(5.5, 13.0, 14.5, 16.5, a.linear_multiply(0.5)); + } + "researcher" => { + pen.circle(9.0, 9.0, 5.0, c); + pen.line_c(&[(12.5, 12.5), (17.0, 17.0)], a); + pen.lines(6.5, 11.5, &[8.0, 10.0]); + } + "citation" => { + pen.text(6.0, 9.0, 14.0, "“", a, true); + pen.lines(9.0, 17.0, &[6.0, 10.0, 14.0]); + } + "sources" | "bibliography" => { + pen.rect(4.0, 3.0, 9.0, 17.0, c); + pen.rect(9.0, 3.0, 13.0, 17.0, a); + pen.closed(&[(13.5, 4.0), (16.5, 3.5), (18.0, 16.5), (15.0, 17.0)], c); + } + "caption" => { + pen.rect(3.0, 2.5, 17.0, 11.5, c); + pen.fill(&[(4.5, 10.0), (8.0, 5.5), (11.0, 10.0)], a); + pen.lines(3.0, 13.0, &[15.0, 17.5]); + } + "tableOfFigures" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(6.0, 5.0, 10.0, 8.0, a); + pen.lines(6.0, 14.0, &[11.0, 14.0]); + } + "markEntry" | "index" | "markCitation" | "tableOfAuthorities" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.text(10.0, 10.0, 8.0, if name == "index" || name == "markEntry" { "A" } else { "§" }, a, true); + } + // Mailings + "envelope" => { + pen.rect(2.5, 5.0, 17.5, 15.5, c); + pen.line_c(&[(2.5, 5.0), (10.0, 11.0), (17.5, 5.0)], a); + } + "labels" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.frect(5.0, 5.0, 9.5, 9.0, a); + pen.frect(10.5, 5.0, 15.0, 9.0, a); + pen.frect(5.0, 11.0, 9.5, 15.0, a); + pen.frect(10.5, 11.0, 15.0, 15.0, a); + } + "mailMerge" | "recipients" | "editRecipients" => { + pen.fcircle(7.0, 7.0, 2.5, a); + pen.line(&[(3.0, 15.0), (3.0, 13.0), (7.0, 10.5), (11.0, 13.0), (11.0, 15.0)]); + pen.fcircle(13.5, 6.0, 2.0, c); + pen.line(&[(11.5, 10.0), (13.5, 9.0), (17.0, 11.0), (17.0, 14.0)]); + } + "mergeField" | "addressBlock" | "greetingLine" | "rules" | "matchFields" | "highlightFields" => { + pen.rect(2.5, 6.0, 17.5, 14.0, c); + pen.text(10.0, 10.0, 7.0, "«»", a, true); + } + "preview" => { + pen.text(10.0, 10.0, 8.0, "ABC", a, true); + pen.rect(2.5, 5.0, 17.5, 15.0, c); + } + "next" | "previous" | "first" | "last" => { + let pts: &[(f32, f32)] = if name == "next" || name == "last" { &[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)] } else { &[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)] }; + pen.line_c(pts, a); + } + "finish" => { + pen.page(3.0, 2.0, 13.0, 15.0); + pen.line_c(&[(10.0, 15.0), (13.0, 18.0), (18.0, 11.0)], green); + } + "checkErrors" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.fcircle(14.0, 14.0, 3.5, red); + } + // Review + "thesaurus" => { + pen.rect(3.5, 2.5, 16.5, 17.5, c); + pen.text(10.0, 9.5, 8.0, "Aa", a, true); + } + "wordCount" => pen.text(10.0, 10.0, 9.0, "123", a, true), + "readAloud" => { + pen.text(6.0, 10.0, 10.0, "A", c, false); + pen.line_c(&[(12.0, 7.0), (13.5, 10.0), (12.0, 13.0)], a); + pen.line_c(&[(14.5, 5.0), (17.0, 10.0), (14.5, 15.0)], a); + } + "accessibility" => { + pen.fcircle(10.0, 4.0, 1.6, a); + pen.line(&[(4.0, 7.0), (16.0, 7.0)]); + pen.line(&[(10.0, 7.0), (10.0, 12.0), (7.0, 17.5)]); + pen.line(&[(10.0, 12.0), (13.0, 17.5)]); + } + "translate" | "language" => { + pen.text(6.5, 7.0, 9.0, "A", c, true); + pen.text(13.5, 13.5, 9.0, "あ", a, false); + } + "deleteComment" => { + pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c); + pen.line_c(&[(7.5, 6.0), (12.5, 11.0)], red); + pen.line_c(&[(12.5, 6.0), (7.5, 11.0)], red); + } + "prevComment" | "nextComment" => { + pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c); + let pts: &[(f32, f32)] = if name == "nextComment" { &[(8.0, 6.0), (12.0, 8.5), (8.0, 11.0)] } else { &[(12.0, 6.0), (8.0, 8.5), (12.0, 11.0)] }; + pen.line_c(pts, a); + } + "showComments" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.frect(12.0, 3.0, 17.0, 17.0, a.linear_multiply(0.4)); + } + "resolve" => { + pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c); + pen.line_c(&[(6.5, 8.5), (9.0, 11.0), (13.5, 6.0)], green); + } + "trackChanges" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.line_c(&[(6.0, 7.0), (14.0, 7.0)], red); + pen.line_c(&[(6.0, 11.0), (14.0, 11.0)], a); + pen.lines(6.0, 11.0, &[15.0]); + } + "markup" | "reviewingPane" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.line_c(&[(6.0, 8.0), (13.0, 8.0)], red); + pen.lines(6.0, 13.0, &[12.0]); + } + "accept" => { + pen.page(4.0, 2.0, 15.0, 17.0); + pen.line_c(&[(10.0, 14.5), (12.5, 17.0), (18.0, 11.0)], green); + } + "reject" => { + pen.page(4.0, 2.0, 15.0, 17.0); + pen.line_c(&[(11.0, 12.0), (17.0, 18.0)], red); + pen.line_c(&[(17.0, 12.0), (11.0, 18.0)], red); + } + "prevChange" | "nextChange" => { + pen.page(4.0, 2.0, 16.0, 18.0); + let pts: &[(f32, f32)] = if name == "nextChange" { &[(8.5, 7.0), (11.5, 10.0), (8.5, 13.0)] } else { &[(11.5, 7.0), (8.5, 10.0), (11.5, 13.0)] }; + pen.line_c(pts, a); + } + "compare" | "combine" => { + pen.page(2.0, 3.0, 10.0, 15.0); + pen.page(10.0, 5.0, 18.0, 17.0); + pen.line_c(&[(6.0, 18.5), (14.0, 18.5)], a); + } + "protect" | "restrict" | "blockAuthors" => { + pen.rect(5.0, 9.0, 15.0, 17.5, c); + pen.line_c(&[(7.0, 9.0), (7.0, 6.0), (10.0, 3.0), (13.0, 6.0), (13.0, 9.0)], a); + } + "hideInk" => { + pen.line_c(&[(3.0, 13.0), (6.0, 8.0), (9.0, 13.0), (12.0, 8.0)], a); + pen.line(&[(3.0, 3.0), (17.0, 17.0)]); + } + // View + "readMode" => { + pen.closed(&[(2.0, 4.0), (9.5, 5.0), (9.5, 17.0), (2.0, 16.0)], c); + pen.closed(&[(18.0, 4.0), (10.5, 5.0), (10.5, 17.0), (18.0, 16.0)], a); + } + "printLayout" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.lines(6.0, 14.0, &[7.0, 10.0, 13.0]); + } + "webLayout" => { + pen.circle(10.0, 10.0, 7.0, c); + pen.line_c(&[(3.0, 10.0), (17.0, 10.0)], a); + pen.line_c(&[(10.0, 3.0), (7.0, 10.0), (10.0, 17.0)], a); + pen.line_c(&[(10.0, 3.0), (13.0, 10.0), (10.0, 17.0)], a); + } + "outline" => { + pen.fcircle(4.0, 4.5, 1.2, a); + pen.line(&[(6.5, 4.5), (17.0, 4.5)]); + pen.fcircle(7.0, 10.0, 1.2, a); + pen.line(&[(9.5, 10.0), (17.0, 10.0)]); + pen.fcircle(10.0, 15.5, 1.2, a); + pen.line(&[(12.5, 15.5), (17.0, 15.5)]); + } + "draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]), + "focus" => { + pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]); + pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]); + pen.line(&[(17.0, 13.0), (17.0, 17.0), (13.0, 17.0)]); + pen.line(&[(7.0, 17.0), (3.0, 17.0), (3.0, 13.0)]); + pen.lines(7.0, 13.0, &[8.5, 11.5]); + } + "immersive" => { + pen.closed(&[(2.0, 4.0), (9.5, 5.0), (9.5, 17.0), (2.0, 16.0)], c); + pen.closed(&[(18.0, 4.0), (10.5, 5.0), (10.5, 17.0), (18.0, 16.0)], c); + pen.fcircle(14.0, 10.0, 2.0, a); + } + "vertical" => { + pen.page(5.0, 1.0, 15.0, 9.0); + pen.page(5.0, 11.0, 15.0, 19.0); + } + "sideToSide" => { + pen.page(1.5, 4.0, 9.5, 16.0); + pen.page(10.5, 4.0, 18.5, 16.0); + } + "ruler" => { + pen.rect(2.0, 7.0, 18.0, 13.0, c); + for x in [4.5, 7.0, 9.5, 12.0, 14.5] { + pen.line_c(&[(x, 7.0), (x, 10.0)], a); + } + } + "gridlines" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + for k in [6.5, 10.0, 13.5] { + pen.line_c(&[(k, 3.0), (k, 17.0)], a.linear_multiply(0.6)); + pen.line_c(&[(3.0, k), (17.0, k)], a.linear_multiply(0.6)); + } + } + "navPane" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.frect(3.0, 3.0, 8.0, 17.0, a.linear_multiply(0.5)); + } + "zoom" => { + pen.circle(8.5, 8.5, 5.5, c); + pen.line_c(&[(12.5, 12.5), (17.5, 17.5)], a); + pen.line(&[(6.0, 8.5), (11.0, 8.5)]); + pen.line(&[(8.5, 6.0), (8.5, 11.0)]); + } + "zoom100" => pen.text(10.0, 10.0, 7.5, "100", a, true), + "onePage" => pen.page(5.0, 2.0, 15.0, 18.0), + "multiplePages" => { + pen.page(1.5, 4.0, 9.5, 16.0); + pen.page(10.5, 4.0, 18.5, 16.0); + } + "pageWidth" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.line_c(&[(1.5, 10.0), (18.5, 10.0)], a); + } + "darkMode" => { + pen.fcircle(10.0, 10.0, 7.0, c); + pen.fcircle(13.0, 8.0, 6.0, Color32::from_rgba_unmultiplied(255, 255, 255, 230)); + } + "newWindow" => { + pen.rect(2.5, 5.0, 13.0, 15.0, c); + pen.rect(7.0, 2.5, 17.5, 12.5, a); + } + "arrangeAll" => { + pen.rect(3.0, 2.5, 17.0, 9.0, c); + pen.rect(3.0, 11.0, 17.0, 17.5, a); + } + "split" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.line_c(&[(3.0, 10.0), (17.0, 10.0)], a); + } + "sideBySide" | "syncScroll" | "switchWindows" => { + pen.rect(2.0, 4.0, 9.5, 16.0, c); + pen.rect(10.5, 4.0, 18.0, 16.0, a); + } + "macros" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.fill(&[(8.0, 6.5), (8.0, 13.5), (14.0, 10.0)], a); + } + "properties" | "info" => { + pen.circle(10.0, 10.0, 7.0, c); + pen.line_c(&[(10.0, 9.0), (10.0, 14.5)], a); + pen.fcircle(10.0, 6.3, 1.0, a); + } + // Table tools + "insertAbove" | "insertBelow" | "insertLeft" | "insertRight" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + match name { + "insertAbove" => pen.frect(3.0, 3.0, 17.0, 8.0, a), + "insertBelow" => pen.frect(3.0, 12.0, 17.0, 17.0, a), + "insertLeft" => pen.frect(3.0, 3.0, 8.0, 17.0, a), + _ => pen.frect(12.0, 3.0, 17.0, 17.0, a), + } + } + "deleteTable" => { + pen.rect(3.0, 3.0, 15.0, 15.0, c); + pen.line(&[(3.0, 9.0), (15.0, 9.0)]); + pen.line(&[(9.0, 3.0), (9.0, 15.0)]); + pen.line_c(&[(12.0, 12.0), (18.0, 18.0)], red); + pen.line_c(&[(18.0, 12.0), (12.0, 18.0)], red); + } + "merge" => { + pen.rect(3.0, 5.0, 17.0, 15.0, c); + pen.line_c(&[(5.0, 10.0), (15.0, 10.0)], a); + pen.line_c(&[(7.0, 8.0), (5.0, 10.0), (7.0, 12.0)], a); + pen.line_c(&[(13.0, 8.0), (15.0, 10.0), (13.0, 12.0)], a); + } + "splitCells" | "splitTable" => { + pen.rect(3.0, 5.0, 17.0, 15.0, c); + pen.line_c(&[(10.0, 5.0), (10.0, 15.0)], a); + } + "autofit" | "distributeRows" | "distributeCols" | "rowHeight" | "colWidth" => { + pen.rect(3.0, 4.0, 17.0, 16.0, c); + pen.line_c(&[(5.0, 10.0), (15.0, 10.0)], a); + pen.line_c(&[(7.0, 8.0), (5.0, 10.0), (7.0, 12.0)], a); + pen.line_c(&[(13.0, 8.0), (15.0, 10.0), (13.0, 12.0)], a); + } + "cellAlign" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.lines(6.0, 14.0, &[8.5, 11.5]); + } + "textDirection" => { + pen.text(9.0, 10.0, 10.0, "A", c, false); + pen.line_c(&[(15.5, 4.0), (15.5, 16.0)], a); + pen.line_c(&[(13.5, 14.0), (15.5, 16.0), (17.5, 14.0)], a); + } + "cellMargins" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.rect(6.0, 6.0, 14.0, 14.0, a); + } + "repeatHeader" => { + pen.frect(3.0, 3.0, 17.0, 7.0, a); + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.line(&[(3.0, 12.0), (17.0, 12.0)]); + } + "toText" => { + pen.rect(3.0, 3.0, 11.0, 11.0, c); + pen.lines(9.0, 17.0, &[14.0, 17.0]); + } + "formula" => pen.text(10.0, 10.0, 12.0, "fx", a, false), + "selectTable" => { + pen.rect(3.0, 3.0, 17.0, 17.0, c); + pen.fill(&[(8.0, 7.0), (8.0, 15.0), (10.0, 13.0), (11.5, 16.0), (12.5, 15.5), (11.0, 12.5), (13.5, 12.5)], a); + } + "draw" => { + pen.rect(3.0, 3.0, 13.0, 13.0, c); + pen.line_c(&[(10.0, 17.0), (17.0, 10.0)], a); + } + "borderPainter" => { + pen.line(&[(4.0, 16.0), (14.0, 6.0), (16.0, 8.0), (6.0, 18.0), (3.5, 18.5), (4.0, 16.0)]); + pen.line_c(&[(3.0, 3.0), (11.0, 3.0)], a); + } + // Chrome + "save" => { + pen.closed(&[(3.0, 3.0), (14.0, 3.0), (17.0, 6.0), (17.0, 17.0), (3.0, 17.0)], c); + pen.frect(6.0, 3.0, 13.0, 7.5, a); + pen.rect(6.0, 11.0, 14.0, 17.0, c); + } + "undo" => { + pen.line(&[(5.0, 8.0), (13.0, 8.0)]); + pen.p.add(Shape::line( + (0..=12).map(|i| { + let t = -std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0; + pen.pt(13.0 + 4.5 * t.cos(), 12.5 + 4.5 * t.sin()) + }).collect(), + pen.stroke(c), + )); + pen.line(&[(13.0, 17.0), (8.0, 17.0)]); + pen.fill(&[(2.5, 8.0), (6.5, 4.5), (6.5, 11.5)], a); + } + "redo" => { + pen.line(&[(15.0, 8.0), (7.0, 8.0)]); + pen.p.add(Shape::line( + (0..=12).map(|i| { + let t = std::f32::consts::FRAC_PI_2 + std::f32::consts::PI * i as f32 / 12.0; + pen.pt(7.0 + 4.5 * t.cos(), 12.5 - 4.5 * t.sin()) + }).collect(), + pen.stroke(c), + )); + pen.line(&[(7.0, 17.0), (12.0, 17.0)]); + pen.fill(&[(17.5, 8.0), (13.5, 4.5), (13.5, 11.5)], a); + } + "search" => { + pen.circle(8.5, 8.5, 5.0, c); + pen.line(&[(12.0, 12.0), (16.5, 16.5)]); + } + "share" => { + pen.line(&[(10.0, 3.0), (10.0, 12.0)]); + pen.line(&[(6.5, 6.5), (10.0, 3.0), (13.5, 6.5)]); + pen.line(&[(5.0, 9.0), (3.5, 9.0), (3.5, 17.0), (16.5, 17.0), (16.5, 9.0), (15.0, 9.0)]); + } + "user" => { + pen.fcircle(10.0, 7.0, 3.5, a); + pen.p.add(Shape::convex_polygon( + (0..=16).map(|i| { + let t = std::f32::consts::PI * i as f32 / 16.0; + pen.pt(10.0 - 6.5 * t.cos(), 18.0 - 6.0 * t.sin()) + }).collect(), + a, + Stroke::NONE, + )); + } + "close" => { + pen.line(&[(5.0, 5.0), (15.0, 15.0)]); + pen.line(&[(15.0, 5.0), (5.0, 15.0)]); + } + "dropdown" => pen.arrow_down(10.0, 10.0, c), + "more" => { + for x in [5.0, 10.0, 15.0] { + pen.fcircle(x, 10.0, 1.3, c); + } + } + "launcher" => { + pen.line(&[(5.0, 5.0), (5.0, 15.0), (15.0, 15.0)]); + pen.line(&[(8.0, 8.0), (15.0, 15.0)]); + pen.line(&[(11.0, 15.0), (15.0, 15.0), (15.0, 11.0)]); + } + "pin" => { + pen.line(&[(10.0, 12.0), (10.0, 18.0)]); + pen.rect(7.0, 2.0, 13.0, 9.0, c); + pen.line(&[(5.0, 12.0), (15.0, 12.0)]); + } + "plus" => { + pen.line(&[(10.0, 4.0), (10.0, 16.0)]); + pen.line(&[(4.0, 10.0), (16.0, 10.0)]); + } + "minus" => pen.line(&[(4.0, 10.0), (16.0, 10.0)]), + "check" => pen.line_c(&[(4.0, 10.5), (8.5, 15.0), (16.5, 5.0)], c), + "chevronLeft" => pen.line(&[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)]), + "chevronRight" => pen.line(&[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)]), + "chevronUp" => pen.line(&[(4.0, 13.0), (10.0, 7.0), (16.0, 13.0)]), + "chevronDown" => pen.line(&[(4.0, 7.0), (10.0, 13.0), (16.0, 7.0)]), + "addins" => { + pen.rect(3.0, 3.0, 9.0, 9.0, c); + pen.rect(11.0, 3.0, 17.0, 9.0, c); + pen.rect(3.0, 11.0, 9.0, 17.0, c); + pen.line_c(&[(14.0, 11.0), (14.0, 17.0)], a); + pen.line_c(&[(11.0, 14.0), (17.0, 14.0)], a); + } + "discord" => { + pen.fill(&[(3.0, 6.0), (7.0, 3.5), (13.0, 3.5), (17.0, 6.0), (17.5, 14.0), (13.5, 16.5), (6.5, 16.5), (2.5, 14.0)], a); + pen.fcircle(7.5, 10.5, 1.5, Color32::WHITE); + pen.fcircle(12.5, 10.5, 1.5, Color32::WHITE); + } + "help" => { + pen.circle(10.0, 10.0, 7.0, c); + pen.text(10.0, 10.0, 10.0, "?", a, true); + } + _ => { + // Fallback: a rounded tile with the first letter. + p.rect_stroke(r.shrink(r.width() * 0.12), 3.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); + let ch = name.chars().next().map(|x| x.to_ascii_uppercase().to_string()).unwrap_or_default(); + p.text(r.center(), egui::Align2::CENTER_CENTER, ch, FontId::proportional(r.height() * 0.45), a); + } + } +} + +trait ThenLines { + fn then_lines(self, pen: &Pen, x0: f32, x1: f32, ys: &[f32]); +} + +impl ThenLines for () { + fn then_lines(self, pen: &Pen, x0: f32, x1: f32, ys: &[f32]) { + pen.lines(x0, x1, ys); + } +} diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs new file mode 100644 index 000000000..32051c8d2 --- /dev/null +++ b/crates/ui-egui/src/keys.rs @@ -0,0 +1,175 @@ +//! Keyboard: typing, editing keys and command shortcuts (from the registry's `shortcut` fields). + +use egui::{Key, Modifiers}; +use serde_json::{Value, json}; + +use crate::WordApp; + +/// The registry's name for a key. +pub fn key_name(k: Key) -> Option<&'static str> { + Some(match k { + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Home => "Home", + Key::End => "End", + Key::PageUp => "PageUp", + Key::PageDown => "PageDown", + Key::Enter => "Enter", + Key::Tab => "Tab", + Key::Backspace => "Backspace", + Key::Delete => "Delete", + Key::Escape => "Escape", + Key::Space => "Space", + Key::Equals | Key::Plus => "=", + Key::Minus => "-", + Key::Period => ".", + Key::Comma => ",", + Key::OpenBracket => "[", + Key::CloseBracket => "]", + Key::Num0 => "0", + Key::Num1 => "1", + Key::Num2 => "2", + Key::Num3 => "3", + Key::Num5 => "5", + Key::Num8 => "8", + Key::Num9 => "9", + Key::F3 => "F3", + Key::F5 => "F5", + Key::F7 => "F7", + Key::F9 => "F9", + Key::F12 => "F12", + Key::A => "A", + Key::B => "B", + Key::C => "C", + Key::D => "D", + Key::E => "E", + Key::F => "F", + Key::G => "G", + Key::H => "H", + Key::I => "I", + Key::J => "J", + Key::K => "K", + Key::L => "L", + Key::M => "M", + Key::N => "N", + Key::O => "O", + Key::P => "P", + Key::R => "R", + Key::S => "S", + Key::T => "T", + Key::U => "U", + Key::V => "V", + Key::W => "W", + Key::X => "X", + Key::Y => "Y", + Key::Z => "Z", + _ => return None, + }) +} + +fn combo(m: Modifiers, key: &str, with_shift: bool) -> String { + let mut s = String::new(); + if m.command { + s.push_str("Mod+"); + } + if m.ctrl && !m.command { + s.push_str("Ctrl+"); + } + if m.mac_cmd && m.ctrl { + s.push_str("Ctrl+"); + } + if m.alt { + s.push_str("Alt+"); + } + if m.shift && with_shift { + s.push_str("Shift+"); + } + s.push_str(key); + s +} + +/// Find and run the command bound to this key. Returns true if handled. +fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool { + let Some(name) = key_name(key) else { return false }; + let reg = app.session.registry.clone(); + if let Some(spec) = reg.by_shortcut(&combo(m, name, true)) { + let _ = app.run(spec.id, json!({})); + return true; + } + // Shift extends caret movement. + if m.shift + && let Some(spec) = reg.by_shortcut(&combo(m, name, false)) + && spec.id.starts_with("caret.") + { + let _ = app.run(spec.id, json!({"extend": true})); + return true; + } + false +} + +/// Events for the focused canvas: text, editing keys, clipboard, IME. +pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + for e in events { + match e { + egui::Event::Text(t) => { + let m = ctx.input(|i| i.modifiers); + if m.command || (m.ctrl && !cfg!(target_os = "macos")) { + continue; + } + if t.chars().all(|c| !c.is_control()) && !t.is_empty() { + let _ = app.run("text.insert", json!({"text": t})); + } + } + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.canvas.ime_preedit = text, + egui::Event::Ime(egui::ImeEvent::Commit(text)) => { + app.canvas.ime_preedit.clear(); + if !text.is_empty() { + let _ = app.run("text.insert", json!({"text": text})); + } + } + egui::Event::Paste(t) => { + let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" }; + let _ = app.run(id, json!({"text": t})); + } + egui::Event::Copy | egui::Event::Cut => { + let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; + if let Ok(r) = app.run(id, json!({})) + && let Some(t) = r.get("text").and_then(Value::as_str) + && !t.is_empty() + { + ctx.copy_text(t.to_string()); + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == Key::Escape && app.session.painter.is_some() { + app.session.painter = None; + continue; + } + if key == Key::Escape && matches!(app.session.sel.focus.story, wordcraft_doc::StoryRef::Part(_)) && app.session.sel.is_collapsed() { + let _ = app.run("insert.closeHeader", json!({})); + continue; + } + dispatch(app, key, modifiers); + } + _ => {} + } + } +} + +/// Command shortcuts when the canvas isn't focused but no text field is either. +pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { + if app.canvas.focused || ctx.egui_wants_keyboard_input() { + return; + } + let events = ctx.input(|i| i.events.clone()); + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e + && (modifiers.command || matches!(key, Key::F3 | Key::F5 | Key::F7 | Key::F9 | Key::F12)) + { + dispatch(app, key, modifiers); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 000000000..8e1c6b47b --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,437 @@ +//! WordCraft's egui front end: a Word-style window (title bar with Quick Access Toolbar, ribbon, +//! rulers, page canvas, panes, status bar, Backstage, dialogs) over `wordcraft-engine`. +//! +//! The UI is thin: it reads `Session` state and acts through [`WordApp::run`] (command ids), so +//! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod backstage; +pub mod canvas; +pub mod chrome; +pub mod control; +pub mod dialogs; +pub mod icons; +pub mod keys; +pub mod panes; +pub mod previews; +pub mod ribbon; +pub mod theme; +pub mod widgets; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use wordcraft_engine::Session; + +pub use control::ControlRequest; + +/// Platform services injected by the host (file dialogs, file I/O). +#[derive(Default)] +pub struct Services { + /// Pick a file to open; the argument says what for (`document`, `picture`). + pub pick_open: Option Option>>, + /// Pick a path to save to, given a suggested name. + pub pick_save: Option Option>>, +} + +/// UI state that persists between runs (and that agents can read and set). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct UiState { + pub tab: String, + pub backstage: bool, + pub backstage_page: String, + pub ribbon_collapsed: bool, + pub recent: Vec, + pub dark: bool, + pub nav_tab: String, + pub show_discord: bool, +} + +impl Default for UiState { + fn default() -> Self { + UiState { + tab: "Home".into(), + backstage: false, + backstage_page: "home".into(), + ribbon_collapsed: false, + recent: Vec::new(), + dark: false, + nav_tab: "headings".into(), + show_discord: true, + } + } +} + +/// The application. +pub struct WordApp { + pub session: Session, + pub ui: UiState, + pub services: Services, + pub canvas: canvas::CanvasState, + pub dialog: Option, + pub status_msg: Option<(String, f64)>, + pub previews: previews::Previews, + /// macOS: the window has no title bar; leave room for the traffic lights. + pub integrated_titlebar: bool, + control_rx: Option>, + shot_token: u64, + queued_shots: Vec<(u64, f64, u32)>, + pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, + pub(crate) synthetic: Vec, + styled: bool, + fonts_frames: u32, + applied_dark: Option, + pub frame_ms: f64, + pub quit_requested: bool, + pub autosave: bool, + pub word_count: (u64, usize), + last_autosave: f64, +} + +impl WordApp { + pub fn new(session: Session, services: Services) -> Self { + WordApp { + session, + ui: UiState::default(), + services, + canvas: canvas::CanvasState::default(), + dialog: None, + status_msg: None, + previews: previews::Previews::default(), + integrated_titlebar: false, + control_rx: None, + shot_token: 0, + queued_shots: Vec::new(), + pending_shots: Vec::new(), + synthetic: Vec::new(), + styled: false, + fonts_frames: 0, + applied_dark: None, + frame_ms: 0.0, + quit_requested: false, + autosave: true, + word_count: (0, 0), + last_autosave: 0.0, + } + } + + pub fn with_control(mut self, rx: std::sync::mpsc::Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Run a command; UI-level commands (`ui.*`) are handled here, the rest by the engine. + pub fn run(&mut self, id: &str, params: Value) -> Result { + if let Some(r) = self.ui_command(id, ¶ms) { + return r; + } + let r = self.session.run(id, ¶ms).map_err(|e| e.to_string()); + self.after_command(id); + if let Err(e) = &r { + self.status(e.clone()); + } + r + } + + fn after_command(&mut self, id: &str) { + self.canvas.caret_visible_since = now_ms(); + if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") { + self.canvas.scroll_to_caret = true; + } + for req in std::mem::take(&mut self.session.ui_requests) { + self.handle_request(&req); + } + if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string()) { + if self.ui.recent.first() != Some(&p) { + self.ui.recent.retain(|r| *r != p); + self.ui.recent.insert(0, p); + self.ui.recent.truncate(12); + } + } + if !self.session.status.is_empty() { + let s = std::mem::take(&mut self.session.status); + self.status(s); + } + } + + /// Requests commands make of the UI (open a dialog…). + fn handle_request(&mut self, req: &Value) { + if let Some(what) = req.get("open").and_then(Value::as_str) { + match what { + "openFile" => self.open_dialog(), + "saveAs" => self.save_as_dialog(), + "insertPicture" => self.pick_picture(), + "print" => { + self.ui.backstage = true; + self.ui.backstage_page = "print".into(); + } + "options" => { + self.ui.backstage = true; + self.ui.backstage_page = "options".into(); + } + other => self.dialog = dialogs::Dialog::open(other, self), + } + } + if req.get("close").is_some() { + self.quit_requested = true; + } + } + + /// Commands that live in the UI layer. + fn ui_command(&mut self, id: &str, p: &Value) -> Option> { + let s = |k: &str| p.get(k).and_then(Value::as_str); + Some(Ok(match id { + "ui.tab" => { + let tab = s("tab").unwrap_or("Home").to_string(); + if tab == "File" { + self.ui.backstage = true; + } else { + self.ui.tab = tab; + self.ui.backstage = false; + } + json!({"tab": self.ui.tab}) + } + "ui.backstage" => { + self.ui.backstage = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.backstage); + if let Some(pg) = s("page") { + self.ui.backstage_page = pg.into(); + } + json!({"backstage": self.ui.backstage}) + } + "ui.dialog" => { + self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self); + json!({"dialog": self.dialog.as_ref().map(|d| d.name())}) + } + "ui.closeDialog" => { + self.dialog = None; + json!({}) + } + "ui.collapseRibbon" => { + self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; + json!({"collapsed": self.ui.ribbon_collapsed}) + } + "ui.dark" => { + self.ui.dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.dark); + json!({"dark": self.ui.dark}) + } + "ui.openFileDialog" => { + self.open_dialog(); + json!({}) + } + "ui.discord" => { + self.canvas.open_url = Some("https://discord.gg/artcraft".into()); + json!({}) + } + _ => return None, + })) + } + + pub fn status(&mut self, s: impl Into) { + self.status_msg = Some((s.into(), now_ms())); + } + + fn open_dialog(&mut self) { + let picked = self.services.pick_open.as_ref().and_then(|f| f("document")); + if let Some(path) = picked { + let _ = self.run("file.open", json!({"path": path})); + self.ui.backstage = false; + } + } + + pub fn save_as_dialog(&mut self) { + let name = self.title_stem() + ".docx"; + let picked = self.services.pick_save.as_ref().and_then(|f| f(&name)); + if let Some(path) = picked { + let _ = self.run("file.save", json!({"path": path})); + } + } + + fn pick_picture(&mut self) { + let picked = self.services.pick_open.as_ref().and_then(|f| f("picture")); + if let Some(path) = picked { + let _ = self.run("insert.picture", json!({"path": path})); + } + } + + /// Document title for the title bar. + pub fn title_stem(&self) -> String { + match &self.session.path { + Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Document1".into()), + None => { + if self.session.doc.core.title.is_empty() { + "Document1".into() + } else { + self.session.doc.core.title.clone() + } + } + } + } + + /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. + pub fn logic(&mut self, ctx: &egui::Context) { + if !self.styled { + theme::install_fonts(ctx); + self.styled = true; + } + let dark = self.ui.dark || self.session.view.dark_mode; + if self.applied_dark != Some(dark) { + theme::apply(ctx, &if dark { theme::Tokens::dark() } else { theme::Tokens::light() }); + self.applied_dark = Some(dark); + } + self.drain_control(ctx); + // AutoSave: write a saved document a couple of seconds after the last change. + let now = now_ms(); + if self.autosave && self.session.dirty && self.session.path.is_some() && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 { + self.last_autosave = now; + let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + if ext == "docx" || ext == "json" || ext == "odt" || ext == "rtf" { + let _ = self.session.run("file.save", &json!({})); + } + } + if self.autosave && self.session.dirty && self.session.path.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(1000)); + } + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + #[cfg(not(target_arch = "wasm32"))] + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + { + let path = f.path().to_string_lossy().to_string(); + let lp = path.to_ascii_lowercase(); + let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e)); + let _ = if img { self.run("insert.picture", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) }; + } + } + } + + /// Inject synthetic input events from the control channel (one pointer event per frame). + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + } + + /// Lay out the window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let t0 = now_ms(); + let ctx = ui.ctx().clone(); + // Fonts installed by `logic` take effect on the next frame. + if self.fonts_frames < 2 { + self.fonts_frames += 1; + ctx.request_repaint(); + return; + } + let t = theme::Tokens::get(&ctx); + if self.ui.backstage { + backstage::show(self, ui); + } else { + chrome::title_bar(self, ui); + ribbon::show(self, ui); + chrome::status_bar(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + canvas::show(self, ui); + }); + } + dialogs::show(self, &ctx); + keys::global_shortcuts(self, &ctx); + if let Some(url) = self.canvas.open_url.take() { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + let title = format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + self.frame_ms = now_ms() - t0; + } + + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + self.shot_token += 1; + let token = self.shot_token; + self.queued_shots.push((token, now_ms() + 120.0, 0)); + self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); + } + } + } + self.control_rx = Some(rx); + } + + fn issue_screenshots(&mut self, ctx: &egui::Context) { + let now = now_ms(); + self.queued_shots.retain_mut(|(token, at, frames)| { + *frames += 1; + if now >= *at && *frames >= 3 { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + false + } else { + true + } + }); + if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_shots.is_empty() { + return; + } + let events: Vec<_> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; + Some((token, image.clone())) + } + _ => None, + }) + .collect() + }); + for (token, image) in events { + if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { + let (_, path, reply, _) = self.pending_shots.remove(i); + let _ = reply.send(control::save_screenshot(&image, path.as_deref())); + } + } + let now = now_ms(); + self.pending_shots.retain(|(_, _, reply, deadline)| { + if now < *deadline { + return true; + } + let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); + false + }); + } +} + +pub fn now_ms() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + } + #[cfg(target_arch = "wasm32")] + { + 0.0 + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs new file mode 100644 index 000000000..f7a6b771e --- /dev/null +++ b/crates/ui-egui/src/panes.rs @@ -0,0 +1,242 @@ +//! Side panes: Navigation (headings, pages, search results), Styles, Comments. + +use egui::{Stroke, Ui, vec2}; +use serde_json::{Value, json}; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::WordApp; +use crate::theme::{Tokens, regular, semibold}; + +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if app.session.view.nav_pane { + egui::Panel::left("nav_pane").default_size(260.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| nav(app, ui)); + } + if app.session.view.styles_pane { + egui::Panel::right("styles_pane").default_size(250.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| styles(app, ui)); + } + if app.session.view.comments_pane { + egui::Panel::right("comments_pane").default_size(290.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| comments(app, ui)); + } +} + +fn header(ui: &mut Ui, title: &str) -> bool { + let mut close = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(title).font(semibold(15.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("✕").clicked() { + close = true; + } + }); + }); + ui.add_space(4.0); + close +} + +fn nav(app: &mut WordApp, ui: &mut Ui) { + if header(ui, "Navigation") { + let _ = app.run("view.navigationPane", json!({"value": false})); + return; + } + let qid = egui::Id::new("nav_query"); + let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search document").desired_width(f32::INFINITY)); + if r.changed() { + ui.data_mut(|d| d.insert_temp(qid, q.clone())); + if !q.is_empty() { + let _ = app.session.run("edit.find", &json!({"text": q})); + } else { + app.session.find.results.clear(); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] { + if ui.selectable_label(app.ui.nav_tab == k, l).clicked() { + app.ui.nav_tab = k.into(); + } + } + }); + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| match app.ui.nav_tab.as_str() { + "pages" => { + let l = app.session.layout(); + for (i, p) in l.pages.iter().enumerate() { + let label = format!("Page {}", p.number); + if ui.selectable_label(app.session.page_hint == i, label).clicked() { + let _ = app.run("edit.goto", json!({"page": i + 1})); + } + } + } + "results" => { + let results = app.session.find.results.clone(); + ui.label(egui::RichText::new(format!("{} results", results.len())).small().weak()); + for (a, b) in results.iter().take(500) { + let Some(p) = app.session.doc.para_at(a) else { continue }; + let from = p.clamp(a.off.saturating_sub(30)); + let to = p.clamp((b.off + 40).min(p.len())); + let pre = p.text.get(from..a.off).unwrap_or(""); + let hit = p.text.get(a.off..b.off).unwrap_or(""); + let post = p.text.get(b.off..to).unwrap_or(""); + let mut job = egui::text::LayoutJob::default(); + let fmt = |c| egui::TextFormat { font_id: regular(12.0), color: c, ..Default::default() }; + job.append(pre, 0.0, fmt(ui.visuals().text_color())); + job.append(hit, 0.0, egui::TextFormat { font_id: semibold(12.0), background: egui::Color32::from_rgb(0xFF, 0xF1, 0x76), color: egui::Color32::BLACK, ..Default::default() }); + job.append(post, 0.0, fmt(ui.visuals().text_color())); + job.wrap.max_width = ui.available_width(); + if ui.add(egui::Button::new(job).frame(false)).clicked() { + let _ = app.run("select.range", json!({"anchor": a, "focus": b})); + } + ui.separator(); + } + } + _ => { + let caret = app.session.sel.focus.clone(); + let mut current: Option = None; + let mut items = Vec::new(); + for path in app.session.doc.para_paths(StoryRef::Body) { + let Some(p) = app.session.doc.para(StoryRef::Body, &path) else { continue }; + let rp = app.session.doc.styles.resolve_para(&p.props); + if let Some(lv) = rp.outline_level { + let txt = p.plain_text().trim().to_string(); + if txt.is_empty() || rp.style.starts_with("TOC") { + continue; + } + if path <= caret.path { + current = Some(items.len()); + } + items.push((path, lv, txt)); + } + } + if items.is_empty() { + ui.label(egui::RichText::new("Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document.").weak()); + } + for (i, (path, lv, txt)) in items.into_iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(lv as f32 * 14.0); + let r = ui.selectable_label(current == Some(i), egui::RichText::new(txt).font(if lv == 0 { semibold(12.5) } else { regular(12.5) })); + if r.clicked() { + let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }})); + app.canvas.want_focus = true; + } + }); + } + } + }); +} + +fn styles(app: &mut WordApp, ui: &mut Ui) { + if header(ui, "Styles") { + let _ = app.run("view.stylesPane", json!({"value": false})); + return; + } + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string(); + ui.label(egui::RichText::new(format!("Current style: {}", st.get("styleName").and_then(Value::as_str).unwrap_or(""))).small()); + ui.horizontal(|ui| { + if ui.button("New Style…").clicked() { + app.dialog = crate::dialogs::Dialog::open("newStyle", app); + } + if ui.button("Update to Match").clicked() { + let _ = app.run("styles.updateToMatch", json!({})); + } + if ui.button("Clear").clicked() { + let _ = app.run("format.clear", json!({})); + } + }); + ui.separator(); + let mut list: Vec<(String, String, bool)> = app.session.doc.styles.styles.iter().filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table).map(|s| (s.id.clone(), s.name.clone(), s.kind == wordcraft_doc::StyleKind::Character)).collect(); + list.sort_by_key(|s| s.1.to_lowercase()); + egui::ScrollArea::vertical().show(ui, |ui| { + for (id, name, is_char) in list { + ui.horizontal(|ui| { + let r = ui.selectable_label(id == cur, &name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(if is_char { "a" } else { "¶" }).weak()); + }); + if r.clicked() { + let _ = app.run("para.style", json!({"style": id})); + app.canvas.want_focus = true; + } + r.context_menu(|ui| { + if ui.button("Modify…").clicked() { + app.dialog = crate::dialogs::Dialog::modify_style(app, &id); + ui.close(); + } + if ui.button("Update to Match Selection").clicked() { + let _ = app.run("styles.updateToMatch", json!({"style": id})); + ui.close(); + } + if ui.button("Delete").clicked() { + let _ = app.run("styles.delete", json!({"style": id})); + ui.close(); + } + }); + }); + } + }); +} + +fn comments(app: &mut WordApp, ui: &mut Ui) { + if header(ui, "Comments") { + let _ = app.run("view.commentsPane", json!({"value": false})); + return; + } + if ui.button("➕ New comment").clicked() { + let _ = app.run("review.newComment", json!({"text": ""})); + } + ui.separator(); + let list = app.session.run("review.comments", &json!({})).unwrap_or_default(); + let t = Tokens::get(ui.ctx()); + egui::ScrollArea::vertical().show(ui, |ui| { + for c in list.as_array().cloned().unwrap_or_default() { + let id = c.get("id").and_then(Value::as_u64).unwrap_or(0); + let reply = c.get("parent").is_some_and(|p| !p.is_null()); + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| { + ui.set_width(ui.available_width()); + if reply { + ui.add_space(0.0); + } + ui.horizontal(|ui| { + ui.label(egui::RichText::new(c.get("author").and_then(Value::as_str).unwrap_or("")).font(semibold(12.0))); + ui.label(egui::RichText::new(c.get("date").and_then(Value::as_str).unwrap_or("").get(..10).unwrap_or("")).small().weak()); + if c.get("resolved").and_then(Value::as_bool).unwrap_or(false) { + ui.label(egui::RichText::new("Resolved").small().color(t.green)); + } + }); + // Editable comment text (writes back to the comment's story). + let part = app.session.doc.comments.get(&(id as u32)).map(|x| x.part); + if let Some(part) = part { + let key = egui::Id::new(("comment_edit", id)); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part))); + let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text("Add a comment")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text.clone())); + } + if r.lost_focus() { + let blocks = text.split('\n').map(|l| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(l, Default::default()))).collect(); + let _ = app.session.doc.set_story(StoryRef::Part(part), blocks); + app.session.touch(); + ui.data_mut(|d| d.remove::(key)); + } + } + ui.horizontal(|ui| { + if ui.small_button("Go to").clicked() + && let Some(a) = c.get("anchor").filter(|a| !a.is_null()) + { + let _ = app.run("caret.set", json!({"pos": a})); + } + if ui.small_button("Resolve").clicked() { + let _ = app.run("review.resolveComment", json!({"id": id})); + } + if ui.small_button("Delete").clicked() { + let _ = app.run("review.deleteComment", json!({"id": id})); + } + }); + }); + ui.add_space(6.0); + } + }); + let _ = vec2(0.0, 0.0); +} diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs new file mode 100644 index 000000000..137ec7b40 --- /dev/null +++ b/crates/ui-egui/src/previews.rs @@ -0,0 +1,271 @@ +//! Rendered previews: the Styles gallery, font menu entries, table and style-set tiles. Each is a +//! tiny document laid out and rasterised by the real engine, cached as a texture. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{Rect, Response, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Block, Document, Paragraph, Table, para_block}; + +use crate::WordApp; +use crate::theme::{Tokens, regular}; + +#[derive(Default)] +pub struct Previews { + tex: HashMap, + families: Option>, + styles_hash: (u64, u64), + /// Previews rendered this frame (rendering is spread over frames). + budget: std::cell::Cell, + frame: u64, +} + +impl Previews { + pub fn families(&mut self) -> Vec { + if self.families.is_none() { + let mut f: Vec = wordcraft_fonts::FontDb::global().families().into_iter().filter(|f| !f.starts_with('.')).collect(); + for w in [wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT] { + if !f.iter().any(|x| x == w) { + f.push(w.to_string()); + } + } + f.sort_by_key(|a| a.to_lowercase()); + f.dedup(); + self.families = Some(f); + } + self.families.clone().unwrap_or_default() + } + + /// A closure drawing a font-menu entry in its own face. + pub fn font_preview_fn(&mut self) -> Box Response> { + Box::new(|ui: &mut Ui, name: &str| { + let key = format!("font:{name}"); + let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::new(&key))); + let (r, resp) = ui.allocate_exact_size(vec2(260.0, 24.0), Sense::click()); + let t = Tokens::get(ui.ctx()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let tex = tex.or_else(|| { + let n = ui.ctx().data_mut(|d| { + let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::new("font_preview_budget")); + let now = ui.input(|i| i.time); + if c.0 != now { + *c = (now, 0); + } + c.1 += 1; + c.1 + }); + if n > 12 { + ui.ctx().request_repaint(); + return None; + } + let ppp = ui.ctx().pixels_per_point(); + let img = snippet(&Document::new(), Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }), 240.0, 20.0, ppp, 2.0)?; + let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone())); + Some(h) + }); + match tex { + Some(h) => { + let sz = h.size_vec2() / ui.ctx().pixels_per_point(); + ui.painter().image(h.id(), Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } + None => { + ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text); + } + } + resp + }) + } + + fn get_or(&mut self, ctx: &egui::Context, key: &str, make: impl FnOnce() -> Option) -> Option { + if let Some(t) = self.tex.get(key) { + return Some(t.clone()); + } + let frame = ctx.cumulative_frame_nr(); + if frame != self.frame { + self.frame = frame; + self.budget.set(0); + } + if self.budget.get() > 8 { + ctx.request_repaint(); + return None; + } + self.budget.set(self.budget.get() + 1); + let img = make()?; + let h = ctx.load_texture(key, img, egui::TextureOptions::LINEAR); + if self.tex.len() > 400 { + self.tex.clear(); + } + self.tex.insert(key.to_string(), h.clone()); + Some(h) + } + + /// Hash of the style sheet (recomputed only when the document revision changes). + fn styles_key(&mut self, app_doc: &Document, rev: u64) -> u64 { + if self.styles_hash.0 != rev { + let s = serde_json::to_string(&(&app_doc.styles, &app_doc.settings.theme_colors)).unwrap_or_default(); + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + self.styles_hash = (rev, h.finish()); + } + self.styles_hash.1 + } +} + +/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it. +pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option { + snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None) +} + +fn snippet_blocks(base: &Document, blocks: Vec, w: f32, h: f32, ppp: f32, margin: f32, paper: Option) -> Option { + let mut d = Document::new(); + d.styles = base.styles.clone(); + d.numbering = base.numbering.clone(); + d.body = blocks.into_iter().map(Arc::new).collect(); + d.last_section.page_w = w + 2.0 * margin; + d.last_section.page_h = 2000.0; + d.last_section.margin_left = margin; + d.last_section.margin_right = margin; + d.last_section.margin_top = margin; + d.last_section.margin_bottom = 0.0; + let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + let page = l.pages.first()?; + let mut opts = wordcraft_render::RenderOptions::default(); + if let Some(p) = paper { + opts.paper = p; + } + let img = wordcraft_render::render_area(&d, page, 0.0, 0.0, w + 2.0 * margin, h + margin, ppp, &opts); + let px = img.to_straight(); + Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px)) +} + +/// The Home tab's Styles gallery. +pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { + let t = Tokens::get(ui.ctx()); + let current = state.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string(); + let styles: Vec<(String, String)> = app.session.doc.styles.gallery().iter().map(|s| (s.id.clone(), s.name.clone())).collect(); + let rev = app.session.rev(); + let skey = app.previews.styles_key(&app.session.doc, rev); + let ppp = ui.ctx().pixels_per_point(); + let start = ui.data(|d| d.get_temp::(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let visible = 6usize; + let (area, _) = ui.allocate_exact_size(vec2(visible as f32 * 76.0 + 18.0, crate::widgets::CONTENT_H), Sense::hover()); + ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() { + let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0)); + let resp = ui.interact(r, ui.id().with(("style_tile", id)), Sense::click()); + let active = *id == current; + if active { + ui.painter().rect(r, 3.0, t.checked, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let key = format!("style:{id}:{skey}:{ppp}"); + let doc = &app.session.doc; + let tex = app.previews.get_or(ui.ctx(), &key, || { + let mut p = Paragraph::with_text("AaBbCcDd", CharProps::default()).styled(id); + p.props.space_before = Some(0.0); + p.props.align = Some(wordcraft_doc::Align::Left); + p.props.indent_left = Some(0.0); + p.props.borders = None; + p.props.shading = None; + snippet(doc, p, 220.0, 30.0, ppp, 1.0) + }); + if let Some(h) = tex { + let sz = h.size_vec2() / ppp; + let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 4.0), sz.min(vec2(68.0, 32.0))); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); + ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE); + } + ui.painter().text(pos2(r.center().x, r.max.y - 9.0), egui::Align2::CENTER_CENTER, name, regular(10.5), t.text); + if resp.on_hover_text(name).clicked() { + let _ = app.run("para.style", json!({"style": id})); + } + } + // Scroll arrows and "more". + let ar = Rect::from_min_max(pos2(area.max.x - 16.0, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0)); + let thirds = ar.height() / 3.0; + for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() { + let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds)); + let resp = ui.interact(br, ui.id().with(("gal_arrow", i)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(br, 2.0, t.hover); + } + crate::icons::paint(ui.painter(), br.shrink(2.0), icon, t.icon, t.accent); + if resp.clicked() { + if *delta == 0 { + let _ = app.run("view.stylesPane", json!({"value": true})); + } else { + let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; + ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n)); + } + } + } +} + +/// Design tab: style set tiles. +pub fn style_set_gallery(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + for name in ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"] { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, crate::widgets::CONTENT_H), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let key = format!("set:{name}:{ppp}"); + let tex = app.previews.get_or(ui.ctx(), &key, || { + let mut s = wordcraft_engine::Session::new(Document::new()); + let _ = s.run("design.styleSet", &json!({"name": name})); + let blocks = vec![ + Block::Para(Paragraph::with_text("Title", CharProps::default()).styled("Title")), + Block::Para(Paragraph::with_text("Heading 1", CharProps::default()).styled("Heading1")), + Block::Para(Paragraph::with_text("Body text in a short paragraph to show spacing.", CharProps::default())), + ]; + snippet_blocks(&s.doc, blocks, 120.0, 110.0, ppp * 0.42, 6.0, None) + }); + if let Some(h) = tex { + let sz = h.size_vec2() / ppp; + let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 3.0), sz.min(vec2(50.0, 60.0))); + ui.painter().rect_stroke(ir, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); + ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE); + } + if resp.on_hover_text(format!("Style set: {name}")).clicked() { + let _ = app.run("design.styleSet", json!({"name": name})); + } + } +} + +/// Table Design: a tile previewing a table style. +pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + let (r, resp) = ui.allocate_exact_size(vec2(58.0, 46.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let key = format!("tstyle:{style}:{ppp}"); + let doc = &app.session.doc; + let tex = app.previews.get_or(ui.ctx(), &key, || { + let mut tb = Table::new(5, 4, 100.0); + tb.props.style = Some(style.to_string()); + for row in &mut tb.rows { + for c in &mut row.cells { + c.blocks = vec![para_block(Paragraph::with_text(" ", CharProps { size: Some(5.0), ..Default::default() }))]; + } + } + snippet_blocks(doc, vec![Block::Table(tb), Block::Para(Paragraph::new())], 104.0, 70.0, ppp * 0.48, 4.0, None) + }); + if let Some(h) = tex { + let sz = h.size_vec2() / ppp; + let ir = Rect::from_center_size(r.center(), sz.min(vec2(54.0, 42.0))); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); + ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE); + } + resp +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs new file mode 100644 index 000000000..67c8788bb --- /dev/null +++ b/crates/ui-egui/src/ribbon.rs @@ -0,0 +1,946 @@ +//! The ribbon: tab strip and every tab's groups. Each control runs a command by id. + +use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::theme::{Tokens, medium, regular, semibold}; +use crate::widgets::{CONTENT_H, LABEL_H, big, color_grid, combo, group, menu_button, small, split}; +use crate::{WordApp, icons}; + +pub const TABS: [&str; 11] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Help"]; + +fn in_table(app: &WordApp) -> bool { + app.session.sel.focus.path.cell().is_some() +} + +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + // Tab strip. + egui::Panel::top("tabs").exact_size(30.0).frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 })).show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + let mut tabs: Vec<&str> = TABS.to_vec(); + if in_table(app) { + tabs.push("Table Design"); + tabs.push("Table Layout"); + } + for tab in tabs { + let contextual = tab.starts_with("Table "); + let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(tab.to_string(), medium(12.5), t.text).size().x) + 18.0; + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + let active = app.ui.tab == tab && !app.ui.backstage; + if resp.hovered() && !active { + ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + } + let color = if contextual { t.accent_text } else if active { t.accent_text } else { t.text }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { semibold(12.5) } else { medium(12.5) }, color); + if active { + let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); + ui.painter().rect_filled(u, 2.0, t.accent); + } + if resp.clicked() { + let _ = app.run("ui.tab", json!({"tab": tab})); + } + if resp.double_clicked() && tab != "File" { + let _ = app.run("ui.collapseRibbon", json!({})); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Share, Editing mode, Comments. + let (r, resp) = ui.allocate_exact_size(vec2(74.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", egui::Color32::WHITE, egui::Color32::WHITE); + ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "Share", medium(12.0), egui::Color32::WHITE); + if resp.on_hover_text("Export a copy to share (PDF, Word document)").clicked() { + let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); + } + ui.add_space(6.0); + let track = app.session.doc.settings.track_changes; + ui.menu_button(egui::RichText::new(if track { "✎ Reviewing ▾" } else { "✎ Editing ▾" }).font(regular(12.0)), |ui| { + if ui.selectable_label(!track, "Editing — edit the document directly").clicked() { + let _ = app.run("review.trackChanges", json!({"value": false})); + ui.close(); + } + if ui.selectable_label(track, "Reviewing — edits become suggestions").clicked() { + let _ = app.run("review.trackChanges", json!({"value": true})); + ui.close(); + } + }); + ui.add_space(4.0); + if ui.button(egui::RichText::new("💬 Comments").font(regular(12.0))).clicked() { + let _ = app.run("view.commentsPane", json!({})); + } + }); + }); + }); + if app.ui.ribbon_collapsed { + return; + } + egui::Panel::top("ribbon") + .exact_size(CONTENT_H + LABEL_H + 10.0) + .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + match app.ui.tab.as_str() { + "Home" => home(app, ui), + "Insert" => insert(app, ui), + "Draw" => draw(app, ui), + "Design" => design(app, ui), + "Layout" => layout(app, ui), + "References" => references(app, ui), + "Mailings" => mailings(app, ui), + "Review" => review(app, ui), + "View" => view(app, ui), + "Help" => help(app, ui), + "Table Design" => table_design(app, ui), + "Table Layout" => table_layout(app, ui), + _ => home(app, ui), + } + }); + }); + }); +} + +fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + add(ui); + }); +} + +fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { + let sc = crate::widgets::shortcut_text(app, id); + let enabled = crate::widgets::enabled(app, id); + let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(sc).min_size(vec2(200.0, 0.0))); + if resp.clicked() { + let _ = app.run(id, params); + ui.close(); + } +} + +fn home(app: &mut WordApp, ui: &mut Ui) { + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + group(ui, "Clipboard", None, app, |ui, app| { + menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| { + mi(ui, app, "Paste", "edit.paste", json!({})); + mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); + mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); + }); + stack(ui, |ui| { + small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); + let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false); + if r.clicked() { + ui.ctx().copy_text(app.session.clipboard_text.clone()); + } + let active = app.session.painter.is_some(); + small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active); + }); + }); + group(ui, "Font", Some("format.fontDialog"), app, |ui, app| { + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let fams = app.previews.families(); + let prev = app.previews.font_preview_fn(); + if let Some(f) = combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { + let _ = app.run("format.font", json!({"name": f})); + } + let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") }).unwrap_or_default(); + let sizes: Vec = wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); + if let Some(v) = combo(ui, "size", 52.0, &size, &sizes, None) + && let Ok(x) = v.trim().parse::() + { + let _ = app.run("format.size", json!({"size": x})); + } + ui.add_space(3.0); + small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false); + small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false); + menu_button(ui, app, "case", None, "Change Case", false, |ui, app| { + mi(ui, app, "Sentence case.", "format.changeCase", json!({"mode": "sentence"})); + mi(ui, app, "lowercase", "format.changeCase", json!({"mode": "lower"})); + mi(ui, app, "UPPERCASE", "format.changeCase", json!({"mode": "upper"})); + mi(ui, app, "Capitalize Each Word", "format.changeCase", json!({"mode": "title"})); + mi(ui, app, "tOGGLE cASE", "format.changeCase", json!({"mode": "toggle"})); + }); + small(ui, app, "clear", None, "Clear All Formatting", "format.clear", json!({}), false); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold")); + small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic")); + split(ui, app, "underline", "Underline", "format.underline", json!({}), flag("underline"), None, |ui, app| { + for (l, s) in [("Single", "single"), ("Double", "double"), ("Thick", "thick"), ("Dotted", "dotted"), ("Dashed", "dash"), ("Dot-dash", "dotDash"), ("Wave", "wave"), ("Words only", "words")] { + mi(ui, app, l, "format.underline", json!({"style": s})); + } + }); + small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike")); + small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript")); + small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript")); + ui.add_space(4.0); + menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| { + mi(ui, app, "Outline", "format.outline", json!({})); + mi(ui, app, "Shadow", "format.shadow", json!({})); + mi(ui, app, "Small Caps", "format.smallCaps", json!({})); + mi(ui, app, "All Caps", "format.allCaps", json!({})); + mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({})); + }); + let hl = app.canvas.last_highlight.clone(); + split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| { + let grid = wordcraft_doc::props::Highlight::ALL; + egui::Grid::new("hl").spacing(vec2(3.0, 3.0)).show(ui, |ui| { + for (i, h) in grid.iter().enumerate().skip(1) { + let c = h.rgb().unwrap_or(wordcraft_doc::Rgb::WHITE); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2)); + if resp.on_hover_text(h.name()).clicked() { + app.canvas.last_highlight = h.ooxml().to_string(); + let _ = app.run("format.highlight", json!({"color": h.ooxml()})); + ui.close(); + } + if i % 5 == 0 { + ui.end_row(); + } + } + }); + mi(ui, app, "No Color", "format.highlight", json!({"color": "none"})); + }); + let fc = app.canvas.last_font_color.clone(); + let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32); + split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| { + mi(ui, app, "Automatic", "format.color", json!({"color": "auto"})); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + app.canvas.last_font_color = hex.clone(); + let _ = app.run("format.color", json!({"color": hex})); + ui.close(); + } + }); + }); + }); + }); + group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { + let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); + let align = rp.as_ref().map(|r| r.align).unwrap_or_default(); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| { + ui.label(egui::RichText::new("Bullet Library").small().weak()); + ui.horizontal(|ui| { + for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { + if ui.button(egui::RichText::new(c).size(16.0)).clicked() { + let _ = app.run("para.bullets", json!({"kind": c})); + ui.close(); + } + } + }); + mi(ui, app, "None", "para.bullets", json!({"off": true})); + }); + split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), false, None, |ui, app| { + for (l, k) in [("1. 2. 3.", "numbered"), ("1) 2) 3)", "numberedParen"), ("I. II. III.", "outline"), ("A. B. C.", "upperLetter"), ("a) b) c)", "lowerLetter"), ("i. ii. iii.", "lowerRoman")] { + mi(ui, app, l, "para.numbering", json!({"kind": k})); + } + ui.separator(); + mi(ui, app, "Restart at 1", "para.restartNumbering", json!({})); + mi(ui, app, "None", "para.numbering", json!({"off": true})); + }); + split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), false, None, |ui, app| { + mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); + mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); + ui.separator(); + for lv in 0..5u64 { + mi(ui, app, &format!("Change to Level {}", lv + 1), "para.listLevel", json!({"level": lv})); + } + }); + ui.add_space(4.0); + small(ui, app, "outdent", None, "Decrease Indent", "para.outdent", json!({}), false); + small(ui, app, "indent", None, "Increase Indent", "para.indent", json!({}), false); + ui.add_space(4.0); + small(ui, app, "sort", None, "Sort", "para.sort", json!({}), false); + let marks = app.session.view.marks; + small(ui, app, "pilcrow", None, "Show/Hide ¶", "view.marks", json!({}), marks); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + use wordcraft_doc::Align as A; + small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left); + small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center); + small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); + small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); + ui.add_space(4.0); + menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, |ui, app| { + for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] { + mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v})); + } + ui.separator(); + mi(ui, app, "Add Space Before Paragraph", "para.addSpaceBefore", json!({})); + mi(ui, app, "Remove Space After Paragraph", "para.removeSpaceAfter", json!({})); + mi(ui, app, "Line Spacing Options…", "para.dialog", json!({})); + }); + split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { + mi(ui, app, "No Color", "para.shading", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + app.canvas.last_shading = hex.clone(); + let _ = app.run("para.shading", json!({"color": hex})); + ui.close(); + } + }); + split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| { + for (l, k) in [("Bottom Border", "bottom"), ("Top Border", "top"), ("Left Border", "left"), ("Right Border", "right"), ("No Border", "none"), ("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside")] { + mi(ui, app, l, "para.borders", json!({"kind": k})); + } + ui.separator(); + mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({})); + }); + }); + }); + }); + group(ui, "Styles", Some("styles.pane"), app, |ui, app| { + crate::previews::style_gallery(app, ui, &st); + }); + group(ui, "Editing", None, app, |ui, app| { + stack(ui, |ui| { + if small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked() {} + small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false); + menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| { + mi(ui, app, "Select All", "select.all", json!({})); + mi(ui, app, "Select Paragraph", "select.paragraph", json!({})); + mi(ui, app, "Select Sentence", "select.sentence", json!({})); + }); + }); + }); + group(ui, "Voice", None, app, |ui, app| { + big(ui, app, "dictate", "Dictate", "tools.dictate", json!({}), false); + }); + group(ui, "Editor", None, app, |ui, app| { + big(ui, app, "editor", "Editor", "review.spelling", json!({}), false); + }); +} + +fn insert(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Pages", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, |ui, app| { + mi(ui, app, "Studio cover", "insert.coverPage", json!({})); + }); + small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false); + small(ui, app, "pageBreak", Some("Page Break"), "Page Break", "insert.pageBreak", json!({}), false); + }); + }); + group(ui, "Tables", None, app, |ui, app| { + menu_button(ui, app, "table", Some("Table"), "Add a Table", true, |ui, app| { + crate::dialogs::table_grid_picker(ui, app); + ui.separator(); + mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"})); + mi(ui, app, "Convert Text to Table…", "table.fromText", json!({})); + ui.menu_button("Quick Tables", |ui| { + mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); + mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"})); + mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"})); + }); + }); + }); + group(ui, "Illustrations", None, app, |ui, app| { + big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false); + menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, |ui, app| { + for (l, k) in [("Rectangle", "rectangle"), ("Rounded Rectangle", "roundedRectangle"), ("Oval", "ellipse"), ("Triangle", "triangle"), ("Diamond", "diamond"), ("Line", "line"), ("Arrow", "arrow"), ("Star", "star"), ("Heart", "heart")] { + mi(ui, app, l, "insert.shape", json!({"kind": k})); + } + }); + stack(ui, |ui| { + small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false); + small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", "insert.smartArt", json!({}), false); + small(ui, app, "chart", Some("Chart"), "Chart", "insert.chart", json!({}), false); + }); + }); + group(ui, "Links", None, app, |ui, app| { + stack(ui, |ui| { + small(ui, app, "link", Some("Link"), "Link", "ui.dialog", json!({"name": "link"}), false); + small(ui, app, "bookmark", Some("Bookmark"), "Bookmark", "ui.dialog", json!({"name": "bookmark"}), false); + small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); + }); + }); + group(ui, "Comments", None, app, |ui, app| { + big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false); + }); + group(ui, "Header & Footer", None, app, |ui, app| { + menu_button(ui, app, "header", Some("Header"), "Header", true, |ui, app| { + mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"})); + mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"})); + mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"})); + ui.separator(); + mi(ui, app, "Edit Header", "insert.editHeader", json!({})); + mi(ui, app, "Remove Header", "insert.removeHeader", json!({})); + }); + menu_button(ui, app, "footer", Some("Footer"), "Footer", true, |ui, app| { + mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"})); + mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"})); + mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"})); + ui.separator(); + mi(ui, app, "Edit Footer", "insert.editFooter", json!({})); + mi(ui, app, "Remove Footer", "insert.removeFooter", json!({})); + }); + menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, |ui, app| { + mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"})); + mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"})); + mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"})); + mi(ui, app, "Current Position", "insert.pageNumber", json!({"position": "current"})); + mi(ui, app, "Format Page Numbers…", "layout.pageNumberFormat", json!({})); + }); + }); + group(ui, "Text", None, app, |ui, app| { + big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false); + stack(ui, |ui| { + small(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", "insert.quickParts", json!({}), false); + small(ui, app, "wordArt", Some("WordArt"), "WordArt", "insert.wordArt", json!({}), false); + small(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", "insert.dropCap", json!({}), false); + }); + stack(ui, |ui| { + small(ui, app, "signature", None, "Signature Line", "insert.signatureLine", json!({}), false); + small(ui, app, "dateTime", None, "Date & Time", "insert.dateTime", json!({}), false); + small(ui, app, "object", None, "Object", "insert.object", json!({}), false); + }); + }); + group(ui, "Symbols", None, app, |ui, app| { + big(ui, app, "equation", "Equation", "insert.equation", json!({}), false); + menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, |ui, app| { + egui::Grid::new("syms").show(ui, |ui| { + for (i, c) in ["©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓", "★", "♥", "—", "…"].iter().enumerate() { + if ui.button(egui::RichText::new(*c).size(16.0)).clicked() { + let _ = app.run("insert.symbol", json!({"char": c})); + ui.close(); + } + if i % 6 == 5 { + ui.end_row(); + } + } + }); + }); + }); +} + +fn draw(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Drawing Tools", None, app, |ui, app| { + big(ui, app, "select", "Select", "draw.select", json!({}), false); + big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false); + big(ui, app, "eraser", "Eraser", "draw.eraser", json!({}), false); + big(ui, app, "pen", "Pen", "draw.pen", json!({}), false); + big(ui, app, "pencil", "Pencil", "draw.pencil", json!({}), false); + big(ui, app, "highlight", "Highlighter", "draw.highlighter", json!({}), false); + }); + group(ui, "Convert", None, app, |ui, app| { + big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false); + big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false); + }); + group(ui, "Insert", None, app, |ui, app| { + big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false); + }); + group(ui, "Replay", None, app, |ui, app| { + big(ui, app, "replay", "Ink\nReplay", "draw.replay", json!({}), false); + }); +} + +fn design(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Themes", None, app, |ui, app| { + menu_button(ui, app, "themes", Some("Themes"), "Themes", true, |ui, app| { + for (name, ..) in wordcraft_engine::cmd::design::THEMES { + mi(ui, app, name, "design.theme", json!({"name": name})); + } + }); + }); + group(ui, "Document Formatting", None, app, |ui, app| { + crate::previews::style_set_gallery(app, ui); + stack(ui, |ui| { + menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, |ui, app| { + for (name, ..) in wordcraft_engine::cmd::design::THEMES { + mi(ui, app, name, "design.themeColors", json!({"name": name})); + } + }); + menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, |ui, app| { + for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES { + mi(ui, app, &format!("{name}: {h} / {b}"), "design.themeFonts", json!({"heading": h, "body": b})); + } + }); + }); + stack(ui, |ui| { + menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, |ui, app| { + for (l, v) in [("Default", "default"), ("No Paragraph Space", "none"), ("Compact", "compact"), ("Tight", "tight"), ("Open", "open"), ("Relaxed", "relaxed"), ("Double", "double")] { + mi(ui, app, l, "design.paragraphSpacing", json!({"value": v})); + } + }); + small(ui, app, "effectsDesign", Some("Effects"), "Effects", "design.effects", json!({}), false); + small(ui, app, "setDefault", Some("Set as Default"), "Set as Default", "design.setDefault", json!({}), false); + }); + }); + group(ui, "Page Background", None, app, |ui, app| { + menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, |ui, app| { + for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] { + mi(ui, app, w, "design.watermark", json!({"text": w})); + } + ui.separator(); + mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"})); + mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true})); + }); + menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, |ui, app| { + mi(ui, app, "No Color", "design.pageColor", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + let _ = app.run("design.pageColor", json!({"color": hex})); + ui.close(); + } + }); + menu_button(ui, app, "pageBorders", Some("Page\nBorders"), "Page Borders", true, |ui, app| { + mi(ui, app, "Box", "design.pageBorders", json!({"kind": "box"})); + mi(ui, app, "None", "design.pageBorders", json!({"kind": "none"})); + }); + }); +} + +fn layout(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| { + menu_button(ui, app, "margins", Some("Margins"), "Margins", true, |ui, app| { + for (l, k) in [("Normal 1\" all", "normal"), ("Narrow 0.5\" all", "narrow"), ("Moderate", "moderate"), ("Wide", "wide"), ("Mirrored", "mirrored"), ("Office 2003 Default", "office2003")] { + mi(ui, app, l, "layout.margins", json!({"preset": k})); + } + mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); + }); + menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, |ui, app| { + mi(ui, app, "Portrait", "layout.orientation", json!({"value": "portrait"})); + mi(ui, app, "Landscape", "layout.orientation", json!({"value": "landscape"})); + }); + menu_button(ui, app, "size", Some("Size"), "Size", true, |ui, app| { + for (n, w, h) in wordcraft_geom::PAPER_SIZES { + mi(ui, app, &format!("{n} {:.2}\" × {:.2}\"", w / 72.0, h / 72.0), "layout.size", json!({"name": n})); + } + }); + menu_button(ui, app, "columns", Some("Columns"), "Columns", true, |ui, app| { + mi(ui, app, "One", "layout.columns", json!({"count": 1})); + mi(ui, app, "Two", "layout.columns", json!({"count": 2})); + mi(ui, app, "Three", "layout.columns", json!({"count": 3})); + mi(ui, app, "Left", "layout.columns", json!({"preset": "left"})); + mi(ui, app, "Right", "layout.columns", json!({"preset": "right"})); + }); + stack(ui, |ui| { + menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, |ui, app| { + ui.label(egui::RichText::new("Page Breaks").strong()); + mi(ui, app, "Page", "layout.break", json!({"kind": "page"})); + mi(ui, app, "Column", "layout.break", json!({"kind": "column"})); + mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"})); + ui.label(egui::RichText::new("Section Breaks").strong()); + mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"})); + mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"})); + mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"})); + mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"})); + }); + menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, |ui, app| { + for (l, v) in [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] { + mi(ui, app, l, "layout.lineNumbers", json!({"value": v})); + } + }); + let hy = app.session.doc.settings.auto_hyphenation; + menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, |ui, app| { + mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true})); + mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false})); + }); + }); + }); + group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { + let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); + let Some(rp) = rp else { return }; + egui::Grid::new("layout_para").spacing(vec2(6.0, 4.0)).show(ui, |ui| { + ui.label(egui::RichText::new("Indent").small().strong()); + ui.label(""); + ui.label(egui::RichText::new("Spacing").small().strong()); + ui.end_row(); + let mut l = rp.indent_left / 72.0; + let mut b = rp.space_before; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Left:").small()); + if ui.add(egui::DragValue::new(&mut l).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() { + let _ = app.run("para.indents", json!({"left": l * 72.0})); + } + }); + ui.label(""); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Before:").small()); + if ui.add(egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() { + let _ = app.run("para.spacing", json!({"before": b})); + } + }); + ui.end_row(); + let mut r = rp.indent_right / 72.0; + let mut a = rp.space_after; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Right:").small()); + if ui.add(egui::DragValue::new(&mut r).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() { + let _ = app.run("para.indents", json!({"right": r * 72.0})); + } + }); + ui.label(""); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("After:").small()); + if ui.add(egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() { + let _ = app.run("para.spacing", json!({"after": a})); + } + }); + ui.end_row(); + }); + }); + group(ui, "Arrange", None, app, |ui, app| { + big(ui, app, "position", "Position", "arrange.position", json!({}), false); + big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false); + stack(ui, |ui| { + small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false); + small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false); + small(ui, app, "selectionPane", Some("Selection Pane"), "Selection Pane", "arrange.selectionPane", json!({}), false); + }); + stack(ui, |ui| { + small(ui, app, "align", None, "Align", "arrange.align", json!({}), false); + small(ui, app, "group", None, "Group", "arrange.group", json!({}), false); + small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false); + }); + }); +} + +fn references(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Table of Contents", None, app, |ui, app| { + menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| { + mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3})); + mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"})); + mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({})); + }); + stack(ui, |ui| { + menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, |ui, app| { + mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0})); + for l in 1..=3u64 { + mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l})); + } + }); + small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false); + }); + }); + group(ui, "Footnotes", None, app, |ui, app| { + big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false); + stack(ui, |ui| { + small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false); + small(ui, app, "nextFootnote", Some("Next Footnote"), "Next Footnote", "references.nextFootnote", json!({}), false); + small(ui, app, "showNotes", Some("Show Notes"), "Show Notes", "references.notes", json!({}), false); + }); + }); + group(ui, "Research", None, app, |ui, app| { + big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false); + }); + group(ui, "Citations & Bibliography", None, app, |ui, app| { + big(ui, app, "citation", "Insert\nCitation", "references.citation", json!({}), false); + stack(ui, |ui| { + small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); + small(ui, app, "styles", Some("Style: APA"), "Citation Style", "references.citationStyle", json!({}), false); + small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false); + }); + }); + group(ui, "Captions", None, app, |ui, app| { + big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false); + stack(ui, |ui| { + small(ui, app, "tableOfFigures", Some("Insert Table of Figures"), "Insert Table of Figures", "references.tableOfFigures", json!({}), false); + small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false); + small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false); + }); + }); + group(ui, "Index", None, app, |ui, app| { + big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false); + stack(ui, |ui| { + small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false); + small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false); + }); + }); + group(ui, "Table of Authorities", None, app, |ui, app| { + big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false); + small(ui, app, "tableOfAuthorities", None, "Insert Table of Authorities", "references.tableOfAuthorities", json!({}), false); + }); +} + +fn mailings(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Create", None, app, |ui, app| { + big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false); + big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false); + }); + group(ui, "Start Mail Merge", None, app, |ui, app| { + big(ui, app, "mailMerge", "Start Mail\nMerge", "mailings.start", json!({}), false); + big(ui, app, "recipients", "Select\nRecipients", "mailings.recipients", json!({}), false); + big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false); + }); + group(ui, "Write & Insert Fields", None, app, |ui, app| { + big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false); + big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false); + big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false); + big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false); + stack(ui, |ui| { + small(ui, app, "rules", Some("Rules"), "Rules", "mailings.rules", json!({}), false); + small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false); + }); + }); + group(ui, "Preview Results", None, app, |ui, app| { + big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false); + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false); + small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false); + }); + small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false); + small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false); + }); + }); + group(ui, "Finish", None, app, |ui, app| { + big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false); + }); +} + +fn review(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Proofing", None, app, |ui, app| { + big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false); + stack(ui, |ui| { + small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false); + small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false); + }); + }); + group(ui, "Speech", None, app, |ui, app| { + big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false); + }); + group(ui, "Accessibility", None, app, |ui, app| { + big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false); + }); + group(ui, "Language", None, app, |ui, app| { + big(ui, app, "translate", "Translate", "review.translate", json!({}), false); + big(ui, app, "language", "Language", "review.language", json!({}), false); + }); + group(ui, "Comments", None, app, |ui, app| { + big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false); + stack(ui, |ui| { + small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false); + small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false); + small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false); + }); + let shown = app.session.view.comments_pane; + big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false); + }); + group(ui, "Tracking", None, app, |ui, app| { + let on = app.session.doc.settings.track_changes; + let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false); + let _ = r; + stack(ui, |ui| { + menu_button(ui, app, "markup", Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), "Display for Review", false, |ui, app| { + mi(ui, app, "All Markup", "review.markup", json!({"value": "all"})); + mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"})); + }); + small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false); + }); + }); + group(ui, "Changes", None, app, |ui, app| { + menu_button(ui, app, "accept", Some("Accept"), "Accept", true, |ui, app| { + mi(ui, app, "Accept This Change", "review.accept", json!({})); + mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); + }); + menu_button(ui, app, "reject", Some("Reject"), "Reject", true, |ui, app| { + mi(ui, app, "Reject Change", "review.reject", json!({})); + mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); + }); + stack(ui, |ui| { + small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false); + small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false); + }); + }); + group(ui, "Compare", None, app, |ui, app| { + big(ui, app, "compare", "Compare", "review.compare", json!({}), false); + }); + group(ui, "Protect", None, app, |ui, app| { + big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false); + big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false); + }); +} + +fn view(app: &mut WordApp, ui: &mut Ui) { + let v = app.session.view.clone(); + group(ui, "Views", None, app, |ui, app| { + big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false); + big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false); + big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false); + stack(ui, |ui| { + small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false); + small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false); + }); + }); + group(ui, "Immersive", None, app, |ui, app| { + big(ui, app, "focus", "Focus", "view.focus", json!({}), false); + big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false); + }); + group(ui, "Page Movement", None, app, |ui, app| { + big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false); + big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false); + }); + group(ui, "Show", None, app, |ui, app| { + stack(ui, |ui| { + let mut r = v.ruler; + if ui.checkbox(&mut r, "Ruler").changed() { + let _ = app.run("view.ruler", json!({"value": r})); + } + let mut g = v.gridlines; + if ui.checkbox(&mut g, "Gridlines").changed() { + let _ = app.run("view.gridlines", json!({"value": g})); + } + let mut n = v.nav_pane; + if ui.checkbox(&mut n, "Navigation Pane").changed() { + let _ = app.run("view.navigationPane", json!({"value": n})); + } + }); + }); + group(ui, "Zoom", None, app, |ui, app| { + big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false); + big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false); + stack(ui, |ui| { + small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage"); + small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page); + small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth"); + }); + }); + group(ui, "Dark Mode", None, app, |ui, app| { + big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false); + }); + group(ui, "Window", None, app, |ui, app| { + big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); + big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); + big(ui, app, "split", "Split", "view.split", json!({}), false); + }); + group(ui, "Macros", None, app, |ui, app| { + big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); + }); +} + +fn help(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Help", None, app, |ui, app| { + big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); + big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); + }); + group(ui, "Agents", None, app, |ui, app| { + big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false); + }); +} + +fn table_design(app: &mut WordApp, ui: &mut Ui) { + let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)); + group(ui, "Table Style Options", None, app, |ui, app| { + let Some(l) = look else { return }; + egui::Grid::new("look").show(ui, |ui| { + for (row, items) in [[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], [("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)]].iter().enumerate() { + let _ = row; + for (label, key, val) in items { + let mut v = *val; + if ui.checkbox(&mut v, *label).changed() { + let _ = app.run("table.look", json!({ *key: v })); + } + } + ui.end_row(); + } + }); + }); + group(ui, "Table Styles", None, app, |ui, app| { + let styles: Vec<(String, String)> = app.session.doc.styles.styles.iter().filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden).map(|s| (s.id.clone(), s.name.clone())).collect(); + egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { + ui.horizontal(|ui| { + for (id, name) in styles { + if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { + let _ = app.run("table.style", json!({"style": id})); + } + } + }); + }); + stack(ui, |ui| { + split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { + mi(ui, app, "No Color", "table.shading", json!({"color": null})); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + app.canvas.last_shading = hex.clone(); + let _ = app.run("table.shading", json!({"color": hex})); + ui.close(); + } + }); + }); + }); + group(ui, "Borders", None, app, |ui, app| { + menu_button(ui, app, "borders", Some("Borders"), "Borders", true, |ui, app| { + for (l, k) in [("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside"), ("No Border", "none"), ("Top Border", "top"), ("Bottom Border", "bottom")] { + mi(ui, app, l, "table.borders", json!({"kind": k})); + } + }); + big(ui, app, "borderPainter", "Border\nPainter", "table.borderPainter", json!({}), false); + }); +} + +fn table_layout(app: &mut WordApp, ui: &mut Ui) { + group(ui, "Table", None, app, |ui, app| { + stack(ui, |ui| { + menu_button(ui, app, "selectTable", Some("Select"), "Select", false, |ui, app| { + mi(ui, app, "Select Cell", "table.selectCell", json!({})); + mi(ui, app, "Select Row", "table.selectRow", json!({})); + mi(ui, app, "Select Table", "table.selectTable", json!({})); + }); + let g = app.session.view.gridlines; + small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g); + small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); + }); + }); + group(ui, "Rows & Columns", None, app, |ui, app| { + menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, |ui, app| { + mi(ui, app, "Delete Cells", "table.deleteCells", json!({})); + mi(ui, app, "Delete Columns", "table.deleteColumn", json!({})); + mi(ui, app, "Delete Rows", "table.deleteRow", json!({})); + mi(ui, app, "Delete Table", "table.deleteTable", json!({})); + }); + big(ui, app, "insertAbove", "Insert\nAbove", "table.insertRowAbove", json!({}), false); + stack(ui, |ui| { + small(ui, app, "insertBelow", Some("Insert Below"), "Insert Below", "table.insertRowBelow", json!({}), false); + small(ui, app, "insertLeft", Some("Insert Left"), "Insert Left", "table.insertColumnLeft", json!({}), false); + small(ui, app, "insertRight", Some("Insert Right"), "Insert Right", "table.insertColumnRight", json!({}), false); + }); + }); + group(ui, "Merge", None, app, |ui, app| { + stack(ui, |ui| { + small(ui, app, "merge", Some("Merge Cells"), "Merge Cells", "table.merge", json!({}), false); + small(ui, app, "splitCells", Some("Split Cells"), "Split Cells", "table.split", json!({}), false); + small(ui, app, "splitTable", Some("Split Table"), "Split Table", "table.splitTable", json!({}), false); + }); + }); + group(ui, "Cell Size", None, app, |ui, app| { + menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, |ui, app| { + mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); + mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); + mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); + }); + stack(ui, |ui| { + small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false); + small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false); + }); + }); + group(ui, "Alignment", None, app, |ui, app| { + egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| { + for row in [["topLeft", "topCenter", "topRight"], ["centerLeft", "center", "centerRight"], ["bottomLeft", "bottomCenter", "bottomRight"]] { + for v in row { + small(ui, app, "cellAlign", None, v, "table.cellAlign", json!({"value": v}), false); + } + ui.end_row(); + } + }); + big(ui, app, "textDirection", "Text\nDirection", "table.textDirection", json!({}), false); + big(ui, app, "cellMargins", "Cell\nMargins", "table.cellMargins", json!({}), false); + }); + group(ui, "Data", None, app, |ui, app| { + big(ui, app, "sort", "Sort", "table.sort", json!({}), false); + stack(ui, |ui| { + small(ui, app, "repeatHeader", Some("Repeat Header Rows"), "Repeat Header Rows", "table.repeatHeader", json!({}), false); + small(ui, app, "toText", Some("Convert to Text"), "Convert to Text", "table.toText", json!({}), false); + small(ui, app, "formula", Some("Formula"), "Formula", "table.formula", json!({}), false); + }); + }); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 000000000..ed8a93a45 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,217 @@ +//! Design tokens (light and dark), UI fonts and the egui style. + +use std::sync::Arc; + +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; + +/// WordCraft's app colour (and its darker ink for text on light backgrounds). +pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB); +pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5); + +/// Every colour the UI uses; widgets never hard-code colours. +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub dark: bool, + pub title_bar: Color32, + pub ribbon: Color32, + pub tab_strip: Color32, + pub canvas: Color32, + pub panel: Color32, + pub border: Color32, + pub border_strong: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_disabled: Color32, + pub icon: Color32, + pub hover: Color32, + pub pressed: Color32, + pub checked: Color32, + pub accent: Color32, + pub accent_text: Color32, + pub input: Color32, + pub input_border: Color32, + pub group_label: Color32, + pub ruler: Color32, + pub ruler_margin: Color32, + pub ruler_tick: Color32, + pub status_bar: Color32, + pub selection: Color32, + pub caret: Color32, + pub page_shadow: Color32, + pub menu: Color32, + pub blue: Color32, + pub red: Color32, + pub green: Color32, + pub orange: Color32, +} + +impl Tokens { + pub fn light() -> Self { + Tokens { + dark: false, + title_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0), + ribbon: Color32::from_rgb(0xF9, 0xF9, 0xF9), + tab_strip: Color32::from_rgb(0xF0, 0xF0, 0xF0), + canvas: Color32::from_rgb(0xE6, 0xE6, 0xE6), + panel: Color32::from_rgb(0xFA, 0xFA, 0xFA), + border: Color32::from_rgb(0xE1, 0xE1, 0xE1), + border_strong: Color32::from_rgb(0xC8, 0xC8, 0xC8), + text: Color32::from_rgb(0x24, 0x24, 0x24), + text_dim: Color32::from_rgb(0x61, 0x61, 0x61), + text_disabled: Color32::from_rgb(0xA8, 0xA8, 0xA8), + icon: Color32::from_rgb(0x42, 0x42, 0x42), + hover: Color32::from_rgb(0xE6, 0xE6, 0xE6), + pressed: Color32::from_rgb(0xD6, 0xD6, 0xD6), + checked: Color32::from_rgb(0xDD, 0xE3, 0xF8), + accent: APP_COLOR, + accent_text: APP_INK, + input: Color32::WHITE, + input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7), + group_label: Color32::from_rgb(0x70, 0x70, 0x70), + ruler: Color32::from_rgb(0xFF, 0xFF, 0xFF), + ruler_margin: Color32::from_rgb(0xD4, 0xD4, 0xD4), + ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70), + status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0), + selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48), + caret: Color32::BLACK, + page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22), + menu: Color32::WHITE, + blue: Color32::from_rgb(0x2F, 0x6F, 0xD0), + red: Color32::from_rgb(0xD1, 0x3B, 0x3B), + green: Color32::from_rgb(0x2E, 0x8B, 0x57), + orange: Color32::from_rgb(0xE0, 0x7B, 0x1F), + } + } + pub fn dark() -> Self { + Tokens { + dark: true, + title_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F), + ribbon: Color32::from_rgb(0x29, 0x29, 0x29), + tab_strip: Color32::from_rgb(0x1F, 0x1F, 0x1F), + canvas: Color32::from_rgb(0x14, 0x14, 0x14), + panel: Color32::from_rgb(0x26, 0x26, 0x26), + border: Color32::from_rgb(0x3A, 0x3A, 0x3A), + border_strong: Color32::from_rgb(0x55, 0x55, 0x55), + text: Color32::from_rgb(0xEE, 0xEE, 0xEE), + text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0), + text_disabled: Color32::from_rgb(0x6A, 0x6A, 0x6A), + icon: Color32::from_rgb(0xDD, 0xDD, 0xDD), + hover: Color32::from_rgb(0x3D, 0x3D, 0x3D), + pressed: Color32::from_rgb(0x4A, 0x4A, 0x4A), + checked: Color32::from_rgb(0x2E, 0x3A, 0x66), + accent: Color32::from_rgb(0x7A, 0x93, 0xF0), + accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), + input: Color32::from_rgb(0x1E, 0x1E, 0x1E), + input_border: Color32::from_rgb(0x58, 0x58, 0x58), + group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), + ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E), + ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45), + ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0), + status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F), + selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55), + caret: Color32::BLACK, + page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60), + menu: Color32::from_rgb(0x2B, 0x2B, 0x2B), + blue: Color32::from_rgb(0x6C, 0x9B, 0xEA), + red: Color32::from_rgb(0xEE, 0x6A, 0x6A), + green: Color32::from_rgb(0x5B, 0xC0, 0x8A), + orange: Color32::from_rgb(0xF0, 0xA0, 0x50), + } + } + pub fn get(ctx: &egui::Context) -> Tokens { + if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() } + } +} + +/// Install the UI fonts (Inter, JetBrains Mono) and the craft-fonts Japanese UI face if built in. +pub fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); + }; + add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")); + add(&mut fonts, "InterMedium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")); + add(&mut fonts, "InterSemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")); + add(&mut fonts, "SourceSans", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")); + add(&mut fonts, "Mono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); + if let Some(prop) = fonts.families.get_mut(&FontFamily::Proportional) { + prop.insert(0, "Inter".into()); + prop.push("SourceSans".into()); + } + if let Some(m) = fonts.families.get_mut(&FontFamily::Monospace) { + m.insert(0, "Mono".into()); + } + fonts.families.insert(FontFamily::Name("medium".into()), vec!["InterMedium".into(), "Inter".into(), "SourceSans".into()]); + fonts.families.insert(FontFamily::Name("semibold".into()), vec!["InterSemiBold".into(), "Inter".into(), "SourceSans".into()]); + for f in wordcraft_fonts::japanese_ui_fonts(false).into_iter().take(1) { + fonts.font_data.insert("JpUi".into(), Arc::new(FontData::from_static(f.bytes))); + for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { + if let Some(v) = fonts.families.get_mut(&fam) { + v.push("JpUi".into()); + } + } + } + // Symbols and emoji fall back to egui's defaults (kept in the families). + ctx.set_fonts(fonts); +} + +pub fn medium(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("medium".into())) +} +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("semibold".into())) +} +pub fn regular(size: f32) -> FontId { + FontId::proportional(size) +} + +/// Apply tokens to egui's style. +pub fn apply(ctx: &egui::Context, t: &Tokens) { + let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.ribbon; + v.window_fill = t.menu; + v.extreme_bg_color = t.input; + v.faint_bg_color = t.panel; + v.window_stroke = Stroke::new(1.0, t.border_strong); + v.window_corner_radius = CornerRadius::same(8); + v.menu_corner_radius = CornerRadius::same(6); + v.selection.bg_fill = t.selection; + v.selection.stroke = Stroke::new(1.0, t.accent); + v.hyperlink_color = t.accent_text; + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.bg_fill = t.input; + v.widgets.inactive.weak_bg_fill = t.input; + v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.inactive.corner_radius = CornerRadius::same(4); + 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.border_strong); + v.widgets.hovered.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.hovered.corner_radius = CornerRadius::same(4); + v.widgets.active.bg_fill = t.pressed; + v.widgets.active.weak_bg_fill = t.pressed; + v.widgets.active.fg_stroke = Stroke::new(1.0, t.text); + v.widgets.active.corner_radius = CornerRadius::same(4); + v.widgets.open.bg_fill = t.pressed; + v.widgets.open.weak_bg_fill = t.pressed; + v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 40 }) }; + v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 50 }) }; + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(6.0, 4.0); + s.spacing.button_padding = egui::vec2(8.0, 3.0); + s.spacing.interact_size = egui::vec2(24.0, 22.0); + s.spacing.menu_margin = egui::Margin::same(6); + s.text_styles.insert(egui::TextStyle::Body, regular(12.5)); + s.text_styles.insert(egui::TextStyle::Button, regular(12.5)); + s.text_styles.insert(egui::TextStyle::Small, regular(10.5)); + s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0)); + s.interaction.tooltip_delay = 0.45; + }); +} + +pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { + Color32::from_rgb(c.0, c.1, c.2) +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 000000000..ff494b3cf --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,297 @@ +//! Ribbon widgets: large and small buttons, split buttons, groups, combo fields, colour grids. + +use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::Value; + +use crate::theme::{Tokens, regular}; +use crate::{WordApp, icons}; + +/// Height of the ribbon content area (without group labels). +pub const CONTENT_H: f32 = 66.0; +pub const LABEL_H: f32 = 16.0; + +/// Shortcut text for a command (`⌘B` on macOS, `Ctrl+B` elsewhere). +pub fn shortcut_text(app: &WordApp, id: &str) -> String { + let Some(spec) = app.session.registry.get(id) else { return String::new() }; + let sc = spec.shortcut.split(" / ").next().unwrap_or(""); + if sc.is_empty() { + return String::new(); + } + if cfg!(target_os = "macos") { + sc.replace("Mod+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃") + } else { + sc.replace("Mod+", "Ctrl+") + } +} + +fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response { + let sc = shortcut_text(app, id); + let desc = app.session.registry.get(id).map(|s| s.location).unwrap_or(""); + let enabled = app.session.registry.get(id).map(|s| (s.enabled)(&app.session).is_none()).unwrap_or(true); + resp.on_hover_ui(|ui| { + ui.set_max_width(260.0); + if sc.is_empty() { + ui.label(egui::RichText::new(label).font(crate::theme::semibold(12.0))); + } else { + ui.label(egui::RichText::new(format!("{label} ({sc})")).font(crate::theme::semibold(12.0))); + } + if !desc.is_empty() { + ui.label(egui::RichText::new(desc).small().weak()); + } + if !enabled { + ui.label(egui::RichText::new("Not available right now").small().weak()); + } + }) +} + +/// Whether a command exists and is enabled. +pub fn enabled(app: &WordApp, id: &str) -> bool { + match app.session.registry.get(id) { + Some(s) => (s.enabled)(&app.session).is_none(), + None => id.starts_with("ui."), + } +} + +fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { + let fill = if resp.is_pointer_button_down_on() { + t.pressed + } else if checked { + t.checked + } else if resp.hovered() { + t.hover + } else { + return; + }; + ui.painter().rect_filled(r, CornerRadius::same(4), fill); + if checked { + ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside); + } +} + +/// A large ribbon button: 32 px icon over a (possibly two-line) label. +pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let galley_w = label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max); + let w = (galley_w + 12.0).max(44.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); + let on = enabled(app, id); + bg(ui, r, &resp, false, &t); + let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)); + let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; + icons::paint(ui.painter(), ic, icon, c, a); + let mut y = r.min.y + 43.0; + let lines: Vec<&str> = label.split('\n').collect(); + for (i, l) in lines.iter().enumerate() { + let txt = if menu && i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; + ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), if on { t.text } else { t.text_disabled }); + y += 13.0; + } + let resp = tooltip(app, resp, &label.replace('\n', " "), id); + if resp.clicked() && on && !menu { + let _ = app.run(id, params); + } + resp +} + +/// A small button: 16 px icon with an optional label; `checked` draws the toggled state. +pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, id: &str, params: Value, checked: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0); + let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click()); + let on = enabled(app, id); + bg(ui, r, &resp, checked, &t); + let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)); + let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; + icons::paint(ui.painter(), ic, icon, c, a); + if let Some(l) = label { + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled }); + } + let resp = tooltip(app, resp, tip, id); + if resp.clicked() && on { + let _ = app.run(id, params); + } + resp +} + +/// A split button: the left part runs `id`, the arrow opens a menu filled by `menu`. +pub fn split(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, params: Value, checked: bool, swatch: Option, menu: impl FnOnce(&mut Ui, &mut WordApp)) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click()); + bg(ui, r, &resp, checked, &t); + bg(ui, ar, &aresp, false, &t); + let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0)); + icons::paint(ui.painter(), ic, icon, t.icon, t.accent); + if let Some(sw) = swatch { + ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw); + } + icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent); + let resp = tooltip(app, resp, tip, id); + if resp.clicked() { + let _ = app.run(id, params); + } + egui::Popup::menu(&aresp).show(|ui| { + ui.set_min_width(180.0); + menu(ui, app); + }); +} + +/// A button that only opens a menu (icon + label + ▾). +pub fn menu_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, big_btn: bool, menu: impl FnOnce(&mut Ui, &mut WordApp)) { + let t = Tokens::get(ui.ctx()); + let resp = if big_btn { + let galley_w = label.unwrap_or("").split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max); + let w = (galley_w + 22.0).max(44.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); + bg(ui, r, &resp, false, &t); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, t.icon, t.accent); + let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); + let mut y = r.min.y + 43.0; + for (i, l) in lines.iter().enumerate() { + let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; + ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text); + y += 13.0; + } + resp + } else { + let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0); + let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click()); + bg(ui, r, &resp, false, &t); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, t.icon, t.accent); + if let Some(l) = label { + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), t.text); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent); + resp + }; + let resp = resp.on_hover_text(tip); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(200.0); + menu(ui, app); + }); +} + +/// A ribbon group: content, a centred label below and a divider on the right. +pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) { + let t = Tokens::get(ui.ctx()); + let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x); + let start = ui.cursor().min; + let inner = ui.vertical(|ui| { + ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 }); + ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), egui::Layout::left_to_right(egui::Align::Min), |ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + add(ui, app); + }); + }); + let r = inner.response.rect; + let r = Rect::from_min_max(start, pos2(r.max.x, start.y + CONTENT_H + LABEL_H)); + ui.painter().text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, title, regular(11.0), t.group_label); + if let Some(cmd) = launcher { + let lr = Rect::from_center_size(pos2(r.max.x - 6.0, r.max.y - 8.0), vec2(11.0, 11.0)); + let resp = ui.interact(lr, ui.id().with(("launch", title)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover); + } + icons::paint(ui.painter(), lr, "launcher", t.text_dim, t.accent); + if resp.on_hover_text(format!("{title} settings")).clicked() { + let _ = app.run(cmd, serde_json::json!({})); + } + } + ui.add_space(5.0); + let x = ui.cursor().min.x; + ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border)); + ui.add_space(6.0); +} + +/// A row of small buttons (used inside a vertical stack). +pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + add(ui); + }); +} + +/// Word's standard colour grid (theme colours + tints, standard colours). Returns a picked hex. +pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option { + let mut picked = None; + ui.label(egui::RichText::new("Theme Colors").small().weak()); + let base: Vec = theme.iter().take(10).copied().collect(); + let tint = |c: wordcraft_doc::Rgb, k: f32| { + let f = |v: u8| if k >= 0.0 { (v as f32 + (255.0 - v as f32) * k) as u8 } else { (v as f32 * (1.0 + k)) as u8 }; + wordcraft_doc::Rgb(f(c.0), f(c.1), f(c.2)) + }; + let order = [1usize, 0, 3, 2, 4, 5, 6, 7, 8, 9]; + for k in [0.0f32, 0.8, 0.6, 0.4, -0.25, -0.5] { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, if k == 0.0 { 6.0 } else { 0.0 }); + for i in order { + let Some(c) = base.get(i).copied() else { continue }; + let c = tint(c, k); + if swatch(ui, c) { + picked = Some(c.hex()); + } + } + }); + } + ui.add_space(4.0); + ui.label(egui::RichText::new("Standard Colors").small().weak()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 0.0); + for hex in ["C00000", "FF0000", "FFC000", "FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0"] { + if let Some(c) = wordcraft_doc::Rgb::parse(hex) + && swatch(ui, c) + { + picked = Some(hex.to_string()); + } + } + }); + picked +} + +fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click()); + ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2)); + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(r, 0.0, Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside); + resp.on_hover_text(format!("#{}", c.hex())).clicked() +} + +/// A ribbon-style text field with a dropdown list. Returns a chosen/typed value. +pub fn combo(ui: &mut Ui, id: &str, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option { + let mut out = None; + let edit_id = ui.id().with(("combo_text", id)); + let mut text = ui.data_mut(|d| d.get_temp::(edit_id)).unwrap_or_else(|| current.to_string()); + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + let te = egui::TextEdit::singleline(&mut text).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0)); + let resp = ui.add(te); + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(edit_id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(edit_id)); + } + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + out = Some(text.clone()); + } + let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click()); + ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent); + egui::Popup::menu(&aresp).show(|ui| { + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + ui.set_min_width(width + 60.0); + for it in items { + let clicked = match preview { + Some(p) => p(ui, it).clicked(), + None => ui.selectable_label(it == current, it).clicked(), + }; + if clicked { + out = Some(it.clone()); + ui.close(); + } + } + }); + }); + }); + out +}