commit f35e5c82e5d23f74d2dbcd81541ff4041a25862d Author: luxick Date: Wed Sep 9 21:19:44 2026 +0200 Version 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..58b3d74 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4571 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[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 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[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.4", + "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.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "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.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder-lite" +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.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "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.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[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.5", +] + +[[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.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "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.20", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "image", + "itertools", + "log", + "mime_guess2", + "profiling", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +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.5", +] + +[[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.5", +] + +[[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.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "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", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.20", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[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.20", + "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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsoncanvas-desktop" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_extras", + "image", + "open", + "rfd", + "serde", + "serde_json", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +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 = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "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.20", + "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.20", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +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-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 = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", + "unicase", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + +[[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 2.13.1", + "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.4", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "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.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.1", +] + +[[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.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "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.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "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.5", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[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.1", + "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.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "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.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +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 = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[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 = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +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.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +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.20", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +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.4", + "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.1", + "rustix 1.1.4", + "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.1", + "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.4", + "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.1", + "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.1", + "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.1", + "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.1", + "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.1", + "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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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.4", + "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.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "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.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.20", + "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.1", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "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 = "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.4", + "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.1", + "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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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.4", + "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.5", + "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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.5", + "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.5", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..64282be --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "jsoncanvas-desktop" +version = "0.1.0" +edition = "2024" + +[dependencies] +eframe = "0.36.2" +egui_extras = { version = "0.36.2", features = ["image", "file"] } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } +open = "5.4.3" +rfd = "0.17.2" +serde = { version = "1.0.229", features = ["derive"] } +# `preserve_order` keeps node/edge attributes in the order the spec lists them +# and keeps unknown attributes where they were, so saving a file written by +# another tool produces a small diff instead of a reshuffle. +serde_json = { version = "1.0.151", features = ["preserve_order"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed69fa9 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# JSON Canvas Editor + +A native desktop editor for the [JSON Canvas](https://jsoncanvas.org) 1.0 file +format, written in Rust. It runs on Windows and Linux from a single binary and +reads and writes plain `.canvas` files on the local disk — there is no server, +no account and no hidden state. + +![The editor with a canvas open](docs/screenshot.png) + +## What it does + +**The whole spec.** All four node types (`text`, `file`, `link`, `group`) and +every edge attribute (`fromSide`/`toSide`, `fromEnd`/`toEnd`, `label`, `color`), +plus both `canvasColor` forms — the six presets and `#rrggbb` hex. + +**It does not damage files it does not fully understand.** Unknown top-level +keys, unknown node/edge attributes and even unknown node `type`s are carried +through a load/save cycle untouched, and attributes are written back in the +order the spec lists them, so a save produces a small diff rather than a +reshuffle. + +**Direct manipulation.** + +| Gesture | Result | +| --- | --- | +| Double-click empty canvas | New text note, ready to type in | +| Double-click a note | Edit its Markdown in place | +| Double-click a file/link node | Open it with the system default application | +| Drag a dot on a node's edge | Draw a connection; drop it on empty canvas to create the node too | +| Drag a corner or side handle | Resize | +| Drag a group | Moves the group and everything inside it | +| Drag on empty canvas | Rubber-band selection (hold Shift to add) | +| Middle- or right-drag | Pan | +| Ctrl+wheel | Zoom around the pointer | + +**Markdown in text nodes**: headings, bold/italic/strikethrough, inline and +fenced code, quotes, bullet, numbered and task lists, rules, images and links. +Links are clickable; a link to another `.canvas` file opens it in the editor. + +**File node previews**: images are rendered inline, text files are previewed as +Markdown, and a `subpath` such as `#Design` shows just that section of the file. +Group `background` images are drawn with the fitting their `backgroundStyle` +asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the +canvas, so a canvas plus its files stays portable. + +**The usual editor comforts**: undo/redo of every change, copy/paste/duplicate, +grouping, alignment, z-order, a properties inspector, snap-to-grid, light and +dark themes, and a warning before you lose unsaved work. + +## Building and running + +```sh +cargo run --release # start with an empty canvas +cargo run --release -- board.canvas +``` + +The release binary is self-contained: + +```sh +cargo build --release # target/release/jsoncanvas-desktop[.exe] +``` + +* **Windows** — nothing beyond a Rust toolchain (MSVC or GNU). +* **Linux** — the usual desktop development packages for windowing and + rendering, e.g. on Debian/Ubuntu: + + ```sh + sudo apt install build-essential pkg-config libx11-dev libxcursor-dev \ + libxrandr-dev libxi-dev libgl1-mesa-dev libwayland-dev libxkbcommon-dev + ``` + + File dialogs use the XDG desktop portal, so no GTK development packages are + needed; install `xdg-desktop-portal` and a backend for your desktop if it is + not already present. + +Run the tests with `cargo test` (the model, geometry, Markdown and editing logic +are covered headless — no display required). + +## Keyboard shortcuts + +| | | +| --- | --- | +| Ctrl+N / O / S | New, open, save | +| Ctrl+Shift+S | Save as | +| Ctrl+Z / Ctrl+Shift+Z | Undo, redo | +| Ctrl+C / V / D | Copy, paste, duplicate | +| Ctrl+A | Select everything | +| Ctrl+G | Put the selection in a group | +| Ctrl+I | Show or hide the inspector | +| Delete | Delete the selection | +| F2 | Edit the selected node | +| F | Zoom to the selection | +| Escape | Stop editing, clear the selection | +| Arrow keys | Nudge by 1 (Shift: 10) | +| Ctrl+0 / 1 | Zoom to fit, actual size | + +## How it is put together + +The UI toolkit is [egui](https://github.com/emilk/egui) via `eframe`. An +infinite canvas is essentially one large custom-painted widget, which is what +immediate-mode GUI is good at, and it builds to a single binary on both targets +without a GTK or Qt runtime. Pan and zoom are a transform on a dedicated egui +layer, so real widgets (the in-place Markdown editor, links) keep working while +the canvas is scaled. + +| Module | Responsibility | +| --- | --- | +| `model.rs` | The file format: parsing, writing, round-trip fidelity | +| `document.rs` | Open document: path, dirty state, undo history | +| `geometry.rs` | Node boxes, edge anchors, Bézier routing, hit testing | +| `view.rs` | The canvas: painting, selection, and every gesture | +| `nodes.rs` | Node bodies: Markdown, file previews, links | +| `markdown.rs` | The Markdown subset used by text nodes | +| `inspector.rs` | The properties panel | +| `theme.rs` | Palettes and the six preset colours | +| `app.rs` | Menus, toolbar, dialogs, file handling, shortcuts | + +## Licence + +MIT. diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..1dbb663 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/examples/tour.canvas b/examples/tour.canvas new file mode 100644 index 0000000..9599b14 --- /dev/null +++ b/examples/tour.canvas @@ -0,0 +1,15 @@ +{ + "nodes": [ + {"id": "group-basics", "type": "group", "label": "The four node types", "x": -520, "y": -320, "width": 1100, "height": 480, "color": "6"}, + {"id": "text", "type": "text", "text": "## text\n\nMarkdown: **bold**, *italic*, `code`,\n[links](https://jsoncanvas.org) and lists.\n\n- [x] renders inline\n- [ ] edit with a double-click", "x": -480, "y": -250, "width": 300, "height": 200, "color": "5"}, + {"id": "file", "type": "file", "file": "../README.md", "subpath": "#What it does", "x": -140, "y": -250, "width": 320, "height": 380}, + {"id": "link", "type": "link", "url": "https://jsoncanvas.org/spec/1.0/", "x": 220, "y": -250, "width": 320, "height": 140}, + {"id": "note", "type": "text", "text": "### group\n\nThe box around all of this is a group node. Drag it and everything inside comes along.", "x": 220, "y": -60, "width": 320, "height": 190, "color": "3"}, + {"id": "edges", "type": "text", "text": "## Edges\n\nSides, arrow heads, labels and colours are all part of the format — set them in the inspector.", "x": -480, "y": 240, "width": 320, "height": 160, "color": "4"} + ], + "edges": [ + {"id": "edge-1", "fromNode": "text", "fromSide": "right", "toNode": "file", "toSide": "left", "label": "previews"}, + {"id": "edge-2", "fromNode": "file", "fromSide": "right", "toNode": "link", "toSide": "left", "color": "2"}, + {"id": "edge-3", "fromNode": "text", "fromSide": "bottom", "toNode": "edges", "toSide": "top", "fromEnd": "arrow", "toEnd": "arrow", "label": "both ends"} + ] +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..8acae1d --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1050 @@ +//! The application shell: menus, toolbar, inspector, status bar, dialogs and +//! everything that touches the file system. + +use std::path::{Path, PathBuf}; + +use eframe::egui::{ + self, Button, Context, Key, KeyboardShortcut, Modifiers, RichText, Ui, ViewportCommand, +}; + +use crate::document::Document; +use crate::geometry::{bounds_of, node_rect}; +use crate::inspector; +use crate::model::{Canvas, Node, NodeKind}; +use crate::theme::Palette; +use crate::view::{CanvasView, Clipboard}; + +/// How long a status message stays on screen. +const STATUS_SECONDS: f64 = 5.0; + +/// An action waiting for the user to decide what to do about unsaved changes. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Pending { + New, + Open, + OpenPath(PathBuf), + Quit, +} + +pub struct App { + doc: Document, + view: CanvasView, + clipboard: Clipboard, + dark_mode: bool, + show_inspector: bool, + status: Option<(String, f64)>, + error: Option, + pending: Option, + quitting: bool, + show_about: bool, + show_shortcuts: bool, + title: String, +} + +impl App { + pub fn new(cc: &eframe::CreationContext<'_>, open: Option) -> Self { + egui_extras::install_image_loaders(&cc.egui_ctx); + let mut app = Self { + doc: Document::new(), + view: CanvasView::new(), + clipboard: Clipboard::default(), + dark_mode: true, + show_inspector: true, + status: None, + error: None, + pending: None, + quitting: false, + show_about: false, + show_shortcuts: false, + title: String::new(), + }; + match open { + Some(path) => app.open_path(&path), + None => app.doc.canvas = welcome_canvas(), + } + app + } + + fn palette(&self) -> Palette { + if self.dark_mode { + Palette::dark() + } else { + Palette::light() + } + } + + fn set_status(&mut self, message: impl Into, ctx: &Context) { + self.status = Some((message.into(), ctx.input(|i| i.time))); + } + + // -- file actions ----------------------------------------------------- + + /// Runs `pending` now, or asks about unsaved changes first. + fn guard(&mut self, pending: Pending) -> bool { + if self.doc.is_dirty() { + self.pending = Some(pending); + false + } else { + true + } + } + + fn new_document(&mut self, ctx: &Context) { + if !self.guard(Pending::New) { + return; + } + self.doc = Document::new(); + self.reset_view(); + self.set_status("New canvas", ctx); + } + + fn open_dialog(&mut self, ctx: &Context) { + if !self.guard(Pending::Open) { + return; + } + let mut dialog = rfd::FileDialog::new() + .add_filter("JSON Canvas", &["canvas"]) + .add_filter("All files", &["*"]); + if let Some(dir) = self.doc.base_dir() { + dialog = dialog.set_directory(dir); + } + if let Some(path) = dialog.pick_file() { + self.open_path(&path); + let name = self.doc.display_name(); + self.set_status(format!("Opened {name}"), ctx); + } + } + + fn open_path(&mut self, path: &Path) { + match Document::open(path) { + Ok(doc) => { + self.doc = doc; + self.reset_view(); + } + Err(err) => self.error = Some(err), + } + } + + fn reset_view(&mut self) { + self.view = CanvasView::new(); + self.view.zoom_to_fit(&self.doc.canvas); + } + + /// Saves, asking for a location when the document has never been saved. + /// Returns `true` when the file made it to disk. + fn save(&mut self, ctx: &Context) -> bool { + match self.doc.save() { + Ok(true) => { + let name = self.doc.display_name(); + self.set_status(format!("Saved {name}"), ctx); + true + } + Ok(false) => self.save_as(ctx), + Err(err) => { + self.error = Some(err); + false + } + } + } + + fn save_as(&mut self, ctx: &Context) -> bool { + let mut dialog = rfd::FileDialog::new() + .add_filter("JSON Canvas", &["canvas"]) + .set_file_name(self.doc.display_name()); + if let Some(dir) = self.doc.base_dir() { + dialog = dialog.set_directory(dir); + } + let Some(mut path) = dialog.save_file() else { + return false; + }; + if path.extension().is_none() { + path.set_extension("canvas"); + } + match self.doc.save_as(&path) { + Ok(()) => { + let name = self.doc.display_name(); + self.set_status(format!("Saved {name}"), ctx); + true + } + Err(err) => { + self.error = Some(err); + false + } + } + } + + /// Carries out the action that was waiting for the unsaved-changes answer. + fn run_pending(&mut self, ctx: &Context) { + let Some(pending) = self.pending.take() else { + return; + }; + match pending { + Pending::New => { + self.doc = Document::new(); + self.reset_view(); + } + Pending::Open => self.open_dialog(ctx), + Pending::OpenPath(path) => self.open_path(&path), + Pending::Quit => { + self.quitting = true; + ctx.send_viewport_cmd(ViewportCommand::Close); + } + } + } + + /// Opens a link or a file node target with the system default handler; a + /// `.canvas` file is opened in this editor instead. + fn open_target(&mut self, target: &str, ctx: &Context) { + if target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with("mailto:") + { + if let Err(err) = open::that_detached(target) { + self.error = Some(format!("Could not open {target}: {err}")); + } + return; + } + + let path = crate::nodes::resolve_path(self.doc.base_dir().as_deref(), target); + if path.extension().is_some_and(|e| e == "canvas") { + if self.guard(Pending::OpenPath(path.clone())) { + self.open_path(&path); + let name = self.doc.display_name(); + self.set_status(format!("Opened {name}"), ctx); + } + return; + } + if !path.exists() { + self.error = Some(format!("{} does not exist.", path.display())); + return; + } + if let Err(err) = open::that_detached(&path) { + self.error = Some(format!("Could not open {}: {err}", path.display())); + } + } + + // -- editing actions -------------------------------------------------- + + fn insert_text_node(&mut self, ctx: &Context) { + let at = self.view.centre_in_canvas(); + let id = self + .view + .insert_node(&mut self.doc, NodeKind::Text { text: String::new() }, at); + self.view.edit_node(&id); + self.set_status("Added a text node", ctx); + } + + fn insert_file_node(&mut self, ctx: &Context) { + let mut dialog = rfd::FileDialog::new(); + if let Some(dir) = self.doc.base_dir() { + dialog = dialog.set_directory(dir); + } + let Some(path) = dialog.pick_file() else { + return; + }; + let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); + let at = self.view.centre_in_canvas(); + self.view.insert_node( + &mut self.doc, + NodeKind::File { + file: file.clone(), + subpath: None, + }, + at, + ); + self.set_status(format!("Added {file}"), ctx); + if self.doc.path().is_none() { + self.set_status( + "Added a file node — save the canvas to store the path relative to it", + ctx, + ); + } + } + + fn insert_link_node(&mut self, ctx: &Context) { + let at = self.view.centre_in_canvas(); + self.view.insert_node( + &mut self.doc, + NodeKind::Link { + url: "https://".to_owned(), + }, + at, + ); + self.show_inspector = true; + self.set_status("Added a link node — set its URL in the inspector", ctx); + } + + fn insert_group(&mut self, ctx: &Context) { + let at = self.view.centre_in_canvas(); + self.view.insert_node( + &mut self.doc, + NodeKind::Group { + label: Some("Group".to_owned()), + background: None, + background_style: None, + }, + at, + ); + self.set_status("Added a group", ctx); + } + + /// Wraps the selected nodes in a new group. + fn group_selection(&mut self, ctx: &Context) { + let rects = self + .view + .selection + .nodes + .iter() + .filter_map(|id| self.doc.canvas.node(id)) + .map(node_rect); + let Some(bounds) = bounds_of(rects) else { + self.set_status("Select some nodes first", ctx); + return; + }; + let bounds = bounds.expand(30.0); + let id = self.doc.canvas.fresh_id(); + self.doc.change("Group selection"); + let mut group = Node::new( + id.clone(), + NodeKind::Group { + label: Some("Group".to_owned()), + background: None, + background_style: None, + }, + bounds.left().round() as i32, + (bounds.top() - 10.0).round() as i32, + bounds.width().round() as i32, + (bounds.height() + 10.0).round() as i32, + ); + group.color = None; + self.doc.canvas.nodes.insert(0, group); + self.view.select_node(&id); + self.set_status("Grouped the selection", ctx); + } + + fn duplicate_selection(&mut self, ctx: &Context) { + if let Some(clipboard) = self.view.copy_selection(&self.doc.canvas) { + let message = self + .view + .paste(&mut self.doc, &clipboard, egui::vec2(24.0, 24.0)); + self.set_status(message.replace("Pasted", "Duplicated"), ctx); + } + } + + fn copy_selection(&mut self, ctx: &Context) { + match self.view.copy_selection(&self.doc.canvas) { + Some(clipboard) => { + let count = clipboard.nodes.len(); + self.clipboard = clipboard; + self.set_status(format!("Copied {count} node(s)"), ctx); + } + None => self.set_status("Nothing to copy", ctx), + } + } + + fn paste(&mut self, ctx: &Context) { + if self.clipboard.is_empty() { + return; + } + let clipboard = self.clipboard.clone(); + let message = self + .view + .paste(&mut self.doc, &clipboard, egui::vec2(24.0, 24.0)); + self.set_status(message, ctx); + } + + fn undo(&mut self, ctx: &Context) { + match self.doc.undo() { + Some(label) => self.set_status(format!("Undid: {label}"), ctx), + None => self.set_status("Nothing to undo", ctx), + } + } + + fn redo(&mut self, ctx: &Context) { + match self.doc.redo() { + Some(label) => self.set_status(format!("Redid: {label}"), ctx), + None => self.set_status("Nothing to redo", ctx), + } + } + + fn delete_selection(&mut self, ctx: &Context) { + if let Some(message) = self.view.delete_selection(&mut self.doc) { + self.set_status(message, ctx); + } + } + + // -- keyboard --------------------------------------------------------- + + fn handle_shortcuts(&mut self, ctx: &Context) { + use Key as K; + const CTRL: Modifiers = Modifiers::COMMAND; + let ctrl_shift = Modifiers::COMMAND.plus(Modifiers::SHIFT); + + if consume(ctx, CTRL, K::N) { + self.new_document(ctx); + } + if consume(ctx, CTRL, K::O) { + self.open_dialog(ctx); + } + if consume(ctx, CTRL, K::S) { + self.save(ctx); + } + if consume(ctx, ctrl_shift, K::S) { + self.save_as(ctx); + } + if consume(ctx, CTRL, K::Z) { + self.undo(ctx); + } + if consume(ctx, ctrl_shift, K::Z) || consume(ctx, CTRL, K::Y) { + self.redo(ctx); + } + if consume(ctx, CTRL, K::A) { + self.view.select_all(&self.doc.canvas); + } + if consume(ctx, CTRL, K::C) { + self.copy_selection(ctx); + } + if consume(ctx, CTRL, K::V) { + self.paste(ctx); + } + if consume(ctx, CTRL, K::D) { + self.duplicate_selection(ctx); + } + if consume(ctx, CTRL, K::G) { + self.group_selection(ctx); + } + if consume(ctx, CTRL, K::I) { + self.show_inspector = !self.show_inspector; + } + if consume(ctx, CTRL, K::Num0) { + self.view.zoom_to_fit(&self.doc.canvas); + } + if consume(ctx, CTRL, K::Num1) { + self.view.set_zoom(1.0); + } + if consume(ctx, CTRL, K::Plus) || consume(ctx, CTRL, K::Equals) { + self.view.set_zoom(self.view.zoom() * 1.2); + } + if consume(ctx, CTRL, K::Minus) { + self.view.set_zoom(self.view.zoom() / 1.2); + } + if consume(ctx, CTRL, K::Q) { + self.request_quit(ctx); + } + + // Plain keys only when no text field is focused. + if ctx.egui_wants_keyboard_input() { + return; + } + if ctx.input_mut(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)) { + self.delete_selection(ctx); + } + if ctx.input_mut(|i| i.key_pressed(Key::Escape)) { + self.view.stop_editing(); + self.view.selection.clear(); + } + if ctx.input_mut(|i| i.key_pressed(Key::F2)) + && let Some(id) = self.view.selection.only_node().map(str::to_owned) + { + self.view.edit_node(&id); + } + if ctx.input_mut(|i| i.key_pressed(Key::F)) && !self.view.selection.is_empty() { + self.view.zoom_to_selection(&self.doc.canvas); + } + + // Arrow keys nudge the selection. + let step = if ctx.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 }; + let mut delta = egui::Vec2::ZERO; + for (key, direction) in [ + (Key::ArrowLeft, egui::vec2(-1.0, 0.0)), + (Key::ArrowRight, egui::vec2(1.0, 0.0)), + (Key::ArrowUp, egui::vec2(0.0, -1.0)), + (Key::ArrowDown, egui::vec2(0.0, 1.0)), + ] { + if ctx.input(|i| i.key_pressed(key)) { + delta += direction * step; + } + } + if delta != egui::Vec2::ZERO { + self.view.nudge(&mut self.doc, delta); + self.doc.end_change(); + } + } + + fn request_quit(&mut self, ctx: &Context) { + if self.doc.is_dirty() { + self.pending = Some(Pending::Quit); + } else { + self.quitting = true; + ctx.send_viewport_cmd(ViewportCommand::Close); + } + } + + /// Files dropped on the window: canvases are opened, anything else becomes + /// a file node. + fn handle_dropped_files(&mut self, ctx: &Context) { + let dropped: Vec = ctx.input(|i| { + i.raw + .dropped_files + .iter() + .map(|f| f.path().to_path_buf()) + .collect() + }); + for path in dropped { + if path.extension().is_some_and(|e| e == "canvas") { + if self.guard(Pending::OpenPath(path.clone())) { + self.open_path(&path); + } + } else { + let file = inspector::relative_to(self.doc.base_dir().as_deref(), &path); + let at = self.view.centre_in_canvas(); + self.view.insert_node( + &mut self.doc, + NodeKind::File { + file: file.clone(), + subpath: None, + }, + at, + ); + self.set_status(format!("Added {file}"), ctx); + } + } + } + + // -- chrome ----------------------------------------------------------- + + fn menu_bar(&mut self, ui: &mut Ui, ctx: &Context) { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("File", |ui| { + if item(ui, "New", "Ctrl+N") { + self.new_document(ctx); + } + if item(ui, "Open…", "Ctrl+O") { + self.open_dialog(ctx); + } + ui.separator(); + if item(ui, "Save", "Ctrl+S") { + self.save(ctx); + } + if item(ui, "Save as…", "Ctrl+Shift+S") { + self.save_as(ctx); + } + ui.separator(); + if ui.button("Reload file previews").clicked() { + self.view.previews.clear(); + self.set_status("Reloaded previews", ctx); + ui.close(); + } + ui.separator(); + if item(ui, "Quit", "Ctrl+Q") { + self.request_quit(ctx); + } + }); + + ui.menu_button("Edit", |ui| { + let undo = self + .doc + .undo_label() + .map(|l| format!("Undo {l}")) + .unwrap_or_else(|| "Undo".to_owned()); + if ui + .add_enabled( + self.doc.can_undo(), + Button::new(undo).shortcut_text("Ctrl+Z"), + ) + .clicked() + { + self.undo(ctx); + ui.close(); + } + let redo = self + .doc + .redo_label() + .map(|l| format!("Redo {l}")) + .unwrap_or_else(|| "Redo".to_owned()); + if ui + .add_enabled( + self.doc.can_redo(), + Button::new(redo).shortcut_text("Ctrl+Shift+Z"), + ) + .clicked() + { + self.redo(ctx); + ui.close(); + } + ui.separator(); + if item(ui, "Copy", "Ctrl+C") { + self.copy_selection(ctx); + } + if item(ui, "Paste", "Ctrl+V") { + self.paste(ctx); + } + if item(ui, "Duplicate", "Ctrl+D") { + self.duplicate_selection(ctx); + } + ui.separator(); + if item(ui, "Select all", "Ctrl+A") { + self.view.select_all(&self.doc.canvas); + } + if item(ui, "Delete selection", "Del") { + self.delete_selection(ctx); + } + }); + + ui.menu_button("Insert", |ui| { + if ui.button("Text node").clicked() { + self.insert_text_node(ctx); + ui.close(); + } + if ui.button("File node…").clicked() { + self.insert_file_node(ctx); + ui.close(); + } + if ui.button("Link node").clicked() { + self.insert_link_node(ctx); + ui.close(); + } + if ui.button("Group").clicked() { + self.insert_group(ctx); + ui.close(); + } + ui.separator(); + if item(ui, "Group the selection", "Ctrl+G") { + self.group_selection(ctx); + } + }); + + ui.menu_button("View", |ui| { + if item(ui, "Zoom in", "Ctrl++") { + self.view.set_zoom(self.view.zoom() * 1.2); + } + if item(ui, "Zoom out", "Ctrl+-") { + self.view.set_zoom(self.view.zoom() / 1.2); + } + if item(ui, "Zoom to fit", "Ctrl+0") { + self.view.zoom_to_fit(&self.doc.canvas); + } + if item(ui, "Actual size", "Ctrl+1") { + self.view.set_zoom(1.0); + } + if item(ui, "Zoom to selection", "F") { + self.view.zoom_to_selection(&self.doc.canvas); + } + ui.separator(); + ui.checkbox(&mut self.view.show_grid, "Show grid"); + ui.checkbox(&mut self.view.snap_to_grid, "Snap to grid"); + ui.checkbox(&mut self.show_inspector, "Inspector"); + ui.checkbox(&mut self.dark_mode, "Dark mode"); + }); + + ui.menu_button("Help", |ui| { + if ui.button("Keyboard shortcuts").clicked() { + self.show_shortcuts = true; + ui.close(); + } + if ui.button("JSON Canvas specification").clicked() { + let _ = open::that_detached("https://jsoncanvas.org/spec/1.0/"); + ui.close(); + } + if ui.button("About").clicked() { + self.show_about = true; + ui.close(); + } + }); + }); + } + + fn toolbar(&mut self, ui: &mut Ui, ctx: &Context) { + ui.horizontal(|ui| { + if ui.button("New").on_hover_text("New canvas (Ctrl+N)").clicked() { + self.new_document(ctx); + } + if ui.button("Open").on_hover_text("Open a canvas (Ctrl+O)").clicked() { + self.open_dialog(ctx); + } + if ui.button("Save").on_hover_text("Save (Ctrl+S)").clicked() { + self.save(ctx); + } + ui.separator(); + if ui + .add_enabled(self.doc.can_undo(), Button::new("Undo")) + .on_hover_text("Undo (Ctrl+Z)") + .clicked() + { + self.undo(ctx); + } + if ui + .add_enabled(self.doc.can_redo(), Button::new("Redo")) + .on_hover_text("Redo (Ctrl+Shift+Z)") + .clicked() + { + self.redo(ctx); + } + ui.separator(); + if ui.button("Note").on_hover_text("Add a text node").clicked() { + self.insert_text_node(ctx); + } + if ui.button("File").on_hover_text("Add a file node").clicked() { + self.insert_file_node(ctx); + } + if ui.button("Link").on_hover_text("Add a link node").clicked() { + self.insert_link_node(ctx); + } + if ui.button("Group").on_hover_text("Add a group").clicked() { + self.insert_group(ctx); + } + ui.separator(); + if ui.button("−").on_hover_text("Zoom out (Ctrl+-)").clicked() { + self.view.set_zoom(self.view.zoom() / 1.2); + } + if ui + .button(format!("{:.0}%", self.view.zoom() * 100.0)) + .on_hover_text("Zoom to fit (Ctrl+0)") + .clicked() + { + self.view.zoom_to_fit(&self.doc.canvas); + } + if ui.button("+").on_hover_text("Zoom in (Ctrl++)").clicked() { + self.view.set_zoom(self.view.zoom() * 1.2); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.toggle_value(&mut self.show_inspector, "Inspector"); + ui.toggle_value(&mut self.view.snap_to_grid, "Snap"); + ui.toggle_value(&mut self.view.show_grid, "Grid"); + let icon = if self.dark_mode { "Light theme" } else { "Dark theme" }; + if ui.button(icon).on_hover_text("Switch theme").clicked() { + self.dark_mode = !self.dark_mode; + } + }); + }); + } + + fn status_bar(&mut self, ui: &mut Ui, palette: &Palette, ctx: &Context) { + ui.horizontal(|ui| { + let dirty = if self.doc.is_dirty() { " •" } else { "" }; + ui.label( + RichText::new(format!("{}{dirty}", self.doc.display_name())) + .size(12.0) + .color(palette.text), + ); + ui.separator(); + + if let Some((message, at)) = &self.status { + if ctx.input(|i| i.time) - at < STATUS_SECONDS { + ui.label(RichText::new(message).size(12.0).color(palette.text_muted)); + } else { + self.status = None; + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let selected = self.view.selection.len(); + let summary = format!( + "{} nodes · {} edges{}", + self.doc.canvas.nodes.len(), + self.doc.canvas.edges.len(), + if selected > 0 { + format!(" · {selected} selected") + } else { + String::new() + } + ); + ui.label(RichText::new(summary).size(12.0).color(palette.text_muted)); + }); + }); + } + + // -- dialogs ---------------------------------------------------------- + + fn dialogs(&mut self, ctx: &Context) { + if self.pending.is_some() { + let name = self.doc.display_name(); + egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| { + ui.set_width(360.0); + ui.heading("Unsaved changes"); + ui.add_space(6.0); + ui.label(format!("“{name}” has changes that are not saved yet.")); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Save and continue").clicked() { + if self.save(ctx) { + self.run_pending(ctx); + } else { + self.pending = None; + } + } + if ui.button("Discard changes").clicked() { + // The pending action replaces the document anyway. + self.run_pending(ctx); + } + if ui.button("Cancel").clicked() { + self.pending = None; + } + }); + }); + } + + if let Some(message) = self.error.clone() { + egui::Modal::new(egui::Id::new("error")).show(ctx, |ui| { + ui.set_width(420.0); + ui.heading("Something went wrong"); + ui.add_space(6.0); + ui.label(message); + ui.add_space(12.0); + if ui.button("Close").clicked() { + self.error = None; + } + }); + } + + if self.show_about { + let mut open = true; + egui::Window::new("About") + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label(RichText::new("JSON Canvas Editor").size(16.0).strong()); + ui.add_space(4.0); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.add_space(8.0); + ui.label( + "A native editor for the JSON Canvas 1.0 file format. Everything is \ + stored in plain .canvas files on this computer.", + ); + ui.add_space(8.0); + ui.hyperlink_to("jsoncanvas.org", "https://jsoncanvas.org"); + }); + self.show_about = open; + } + + if self.show_shortcuts { + let mut open = true; + egui::Window::new("Keyboard shortcuts") + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + egui::Grid::new("shortcuts") + .num_columns(2) + .spacing([24.0, 6.0]) + .show(ui, |ui| { + for (keys, what) in SHORTCUTS { + ui.label(RichText::new(*keys).monospace()); + ui.label(*what); + ui.end_row(); + } + }); + }); + self.show_shortcuts = open; + } + } + + fn update_title(&mut self, ctx: &Context) { + let title = format!( + "{}{} — JSON Canvas Editor", + self.doc.display_name(), + if self.doc.is_dirty() { " •" } else { "" } + ); + if title != self.title { + ctx.send_viewport_cmd(ViewportCommand::Title(title.clone())); + self.title = title; + } + } +} + +impl eframe::App for App { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + let palette = self.palette(); + ctx.set_visuals(if self.dark_mode { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }); + + self.update_title(&ctx); + self.handle_shortcuts(&ctx); + self.handle_dropped_files(&ctx); + + // Closing the window with unsaved changes asks first. + if ctx.input(|i| i.viewport().close_requested()) && !self.quitting { + if self.doc.is_dirty() { + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + self.pending = Some(Pending::Quit); + } else { + self.quitting = true; + } + } + + egui::Panel::top("chrome").show(ui, |ui| { + self.menu_bar(ui, &ctx); + ui.add_space(2.0); + self.toolbar(ui, &ctx); + ui.add_space(2.0); + }); + + egui::Panel::bottom("status").show(ui, |ui| { + ui.add_space(2.0); + self.status_bar(ui, &palette, &ctx); + ui.add_space(2.0); + }); + + if self.show_inspector { + egui::Panel::right("inspector") + .default_size(300.0) + .size_range(220.0..=460.0) + .show(ui, |ui| { + let out = inspector::show(ui, &mut self.doc, &mut self.view, &palette); + if let Some(message) = out.status { + self.set_status(message, &ctx); + } + if let Some(target) = out.open_target { + self.open_target(&target, &ctx); + } + }); + } + + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| { + let out = self.view.show(ui, &mut self.doc, &palette); + if let Some(message) = out.status { + self.set_status(message, &ctx); + } + if let Some(target) = out.open_target { + self.open_target(&target, &ctx); + } + }); + + self.dialogs(&ctx); + } +} + +const SHORTCUTS: &[(&str, &str)] = &[ + ("Ctrl+N / Ctrl+O", "New / open a canvas"), + ("Ctrl+S / Ctrl+Shift+S", "Save / save as"), + ("Ctrl+Z / Ctrl+Shift+Z", "Undo / redo"), + ("Ctrl+C / Ctrl+V / Ctrl+D", "Copy / paste / duplicate"), + ("Ctrl+A", "Select everything"), + ("Ctrl+G", "Put the selection in a group"), + ("Ctrl+I", "Show or hide the inspector"), + ("Delete", "Delete the selection"), + ("F2", "Edit the selected node"), + ("F", "Zoom to the selection"), + ("Escape", "Stop editing, clear the selection"), + ("Arrow keys", "Nudge (hold Shift for 10 units)"), + ("Double-click", "New note, or edit the one under the cursor"), + ("Middle / right drag", "Pan the canvas"), + ("Ctrl+wheel", "Zoom"), + ("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"), +]; + +fn consume(ctx: &Context, modifiers: Modifiers, key: Key) -> bool { + ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(modifiers, key))) +} + +/// A menu entry with its shortcut, closing the menu when clicked. +fn item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { + let clicked = ui.add(Button::new(label).shortcut_text(shortcut)).clicked(); + if clicked { + ui.close(); + } + clicked +} + +/// The canvas shown on a fresh start: a tiny tour of the format. +fn welcome_canvas() -> Canvas { + let mut canvas = Canvas::default(); + let mut node = |id: &str, text: &str, x, y, w, h, color: Option<&str>| { + let mut node = Node::new( + id, + NodeKind::Text { + text: text.to_owned(), + }, + x, + y, + w, + h, + ); + node.color = color.and_then(crate::model::Color::parse); + canvas.nodes.push(node); + }; + + node( + "welcome", + "# JSON Canvas\n\nAn open file format for infinite canvases.\n\nEverything you see lives in a plain `.canvas` file.", + -260, + -220, + 320, + 170, + Some("6"), + ); + node( + "nodes", + "## Nodes\n\n- **text** — Markdown, like this one\n- **file** — a document or image\n- **link** — a URL\n- **group** — a labelled box", + 140, + -240, + 280, + 190, + Some("5"), + ); + node( + "edges", + "## Edges\n\nDrag one of the dots on a node to connect it to another.", + 140, + 20, + 280, + 130, + Some("4"), + ); + node( + "start", + "## Try it\n\n1. Double-click anywhere for a new note\n2. Drag the corners to resize\n3. Save with `Ctrl+S`", + -260, + 0, + 320, + 150, + Some("3"), + ); + + canvas.edges.push(crate::model::Edge::new( + "e-welcome-nodes", + "welcome", + crate::model::Side::Right, + "nodes", + crate::model::Side::Left, + )); + canvas.edges.push(crate::model::Edge::new( + "e-nodes-edges", + "nodes", + crate::model::Side::Bottom, + "edges", + crate::model::Side::Top, + )); + canvas.edges.push(crate::model::Edge::new( + "e-welcome-start", + "welcome", + crate::model::Side::Bottom, + "start", + crate::model::Side::Top, + )); + canvas +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_welcome_canvas_is_valid_and_connected() { + let canvas = welcome_canvas(); + assert_eq!(canvas.nodes.len(), 4); + assert_eq!(canvas.edges.len(), 3); + let mut copy = canvas.clone(); + assert_eq!(copy.drop_dangling_edges(), 0); + // And it round-trips through the file format. + assert_eq!(Canvas::from_json(&canvas.to_json()).unwrap(), canvas); + } +} diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 0000000..d319cf1 --- /dev/null +++ b/src/document.rs @@ -0,0 +1,266 @@ +//! The open document: a [`Canvas`] plus its file location, dirty state and +//! undo history. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::model::Canvas; + +/// How many undo steps are kept. +const UNDO_LIMIT: usize = 256; + +/// A canvas together with everything the editor needs to know about it. +pub struct Document { + pub canvas: Canvas, + path: Option, + dirty: bool, + undo: Vec, + redo: Vec, + /// Label of the change currently being recorded, used to coalesce the many + /// small mutations of a single drag into one undo step. + open_change: Option, +} + +struct Snapshot { + canvas: Canvas, + label: String, +} + +impl Default for Document { + fn default() -> Self { + Self::new() + } +} + +impl Document { + pub fn new() -> Self { + Self { + canvas: Canvas::default(), + path: None, + dirty: false, + undo: Vec::new(), + redo: Vec::new(), + open_change: None, + } + } + + /// Reads a `.canvas` file from disk. + pub fn open(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|e| format!("Could not read {}: {e}", path.display()))?; + let canvas = Canvas::from_json(&text) + .map_err(|e| format!("{} is not a valid JSON Canvas file: {e}", path.display()))?; + Ok(Self { + canvas, + path: Some(path.to_path_buf()), + dirty: false, + undo: Vec::new(), + redo: Vec::new(), + open_change: None, + }) + } + + /// Writes the canvas to `path` and makes it the document's location. + pub fn save_as(&mut self, path: &Path) -> Result<(), String> { + fs::write(path, self.canvas.to_json()) + .map_err(|e| format!("Could not write {}: {e}", path.display()))?; + self.path = Some(path.to_path_buf()); + self.dirty = false; + Ok(()) + } + + /// Writes the canvas back to the file it was loaded from. + /// + /// Returns `Ok(false)` when the document has no path yet and the caller + /// needs to ask the user for one. + pub fn save(&mut self) -> Result { + let Some(path) = self.path.clone() else { + return Ok(false); + }; + self.save_as(&path)?; + Ok(true) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + /// The directory the document lives in, used to resolve relative node paths. + pub fn base_dir(&self) -> Option { + self.path.as_ref()?.parent().map(Path::to_path_buf) + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Name shown in the window title and the status bar. + pub fn display_name(&self) -> String { + self.path + .as_ref() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Untitled.canvas".to_owned()) + } + + // -- undo ------------------------------------------------------------- + + /// Records the state before a change. Call this *before* mutating. + /// + /// Repeated calls with the same `label` while a gesture is open (see + /// [`Document::end_change`]) only record the first state, so an entire drag + /// undoes in one step. + pub fn begin_change(&mut self, label: &str) { + if self.open_change.as_deref() == Some(label) { + self.dirty = true; + return; + } + self.push_undo(label); + self.open_change = Some(label.to_owned()); + } + + /// Records a single, self-contained change. + pub fn change(&mut self, label: &str) { + self.end_change(); + self.push_undo(label); + } + + /// Closes the current gesture so the next change starts a new undo step. + pub fn end_change(&mut self) { + self.open_change = None; + } + + fn push_undo(&mut self, label: &str) { + self.undo.push(Snapshot { + canvas: self.canvas.clone(), + label: label.to_owned(), + }); + if self.undo.len() > UNDO_LIMIT { + self.undo.remove(0); + } + self.redo.clear(); + self.dirty = true; + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + pub fn undo_label(&self) -> Option<&str> { + self.undo.last().map(|s| s.label.as_str()) + } + + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|s| s.label.as_str()) + } + + /// Steps back one change, returning its label. + pub fn undo(&mut self) -> Option { + let snapshot = self.undo.pop()?; + self.redo.push(Snapshot { + canvas: std::mem::replace(&mut self.canvas, snapshot.canvas), + label: snapshot.label.clone(), + }); + self.open_change = None; + self.dirty = true; + Some(snapshot.label) + } + + /// Steps forward one change, returning its label. + pub fn redo(&mut self) -> Option { + let snapshot = self.redo.pop()?; + self.undo.push(Snapshot { + canvas: std::mem::replace(&mut self.canvas, snapshot.canvas), + label: snapshot.label.clone(), + }); + self.open_change = None; + self.dirty = true; + Some(snapshot.label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Node, NodeKind}; + + fn text_node(id: &str) -> Node { + Node::new(id, NodeKind::Text { text: id.into() }, 0, 0, 250, 60) + } + + #[test] + fn undo_and_redo_restore_the_canvas() { + let mut doc = Document::new(); + doc.change("Add node"); + doc.canvas.nodes.push(text_node("a")); + assert!(doc.is_dirty()); + + assert_eq!(doc.undo().as_deref(), Some("Add node")); + assert!(doc.canvas.nodes.is_empty()); + assert_eq!(doc.redo().as_deref(), Some("Add node")); + assert_eq!(doc.canvas.nodes.len(), 1); + } + + #[test] + fn a_gesture_collapses_into_one_undo_step() { + let mut doc = Document::new(); + doc.canvas.nodes.push(text_node("a")); + for step in 1..=10 { + doc.begin_change("Move"); + doc.canvas.nodes[0].x = step; + } + doc.end_change(); + doc.undo(); + assert_eq!(doc.canvas.nodes[0].x, 0); + assert!(!doc.can_undo()); + } + + #[test] + fn a_new_change_clears_the_redo_stack() { + let mut doc = Document::new(); + doc.change("One"); + doc.canvas.nodes.push(text_node("a")); + doc.undo(); + doc.change("Two"); + doc.canvas.nodes.push(text_node("b")); + assert!(!doc.can_redo()); + } + + #[test] + fn saving_and_loading_round_trips( + ) -> Result<(), Box> { + let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id())); + fs::create_dir_all(&dir)?; + let path = dir.join("test.canvas"); + + let mut doc = Document::new(); + doc.canvas.nodes.push(text_node("a")); + doc.save_as(&path)?; + assert!(!doc.is_dirty()); + + let loaded = Document::open(&path)?; + assert_eq!(loaded.canvas, doc.canvas); + assert_eq!(loaded.display_name(), "test.canvas"); + + fs::remove_dir_all(&dir)?; + Ok(()) + } + + #[test] + fn opening_a_broken_file_reports_an_error() { + let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bad.canvas"); + fs::write(&path, "{ not json").unwrap(); + let err = match Document::open(&path) { + Ok(_) => panic!("expected the load to fail"), + Err(err) => err, + }; + assert!(err.contains("not a valid JSON Canvas file"), "{err}"); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/geometry.rs b/src/geometry.rs new file mode 100644 index 0000000..6cd8326 --- /dev/null +++ b/src/geometry.rs @@ -0,0 +1,216 @@ +//! Geometry shared by the renderer and the interaction code: node rectangles, +//! edge anchor points and the cubic Bézier curves edges are drawn with. + +use eframe::egui::{Pos2, Rect, Vec2, pos2, vec2}; + +use crate::model::{Node, Side}; + +/// The node's box in canvas coordinates. +pub fn node_rect(node: &Node) -> Rect { + Rect::from_min_size( + pos2(node.x as f32, node.y as f32), + vec2(node.width as f32, node.height as f32), + ) +} + +/// The point on `rect` where an edge attached to `side` starts or ends. +pub fn anchor(rect: Rect, side: Side) -> Pos2 { + match side { + Side::Top => pos2(rect.center().x, rect.top()), + Side::Bottom => pos2(rect.center().x, rect.bottom()), + Side::Left => pos2(rect.left(), rect.center().y), + Side::Right => pos2(rect.right(), rect.center().y), + } +} + +/// The outward pointing unit normal of a side. +pub fn normal(side: Side) -> Vec2 { + match side { + Side::Top => vec2(0.0, -1.0), + Side::Bottom => vec2(0.0, 1.0), + Side::Left => vec2(-1.0, 0.0), + Side::Right => vec2(1.0, 0.0), + } +} + +/// Picks the sides for an edge whose `fromSide`/`toSide` the file leaves out. +/// +/// The dominant axis between the two node centres wins, which is what the +/// reference implementations do. +pub fn auto_sides(from: Rect, to: Rect) -> (Side, Side) { + let delta = to.center() - from.center(); + // Compare the gaps rather than the raw centre distance so that a wide node + // sitting just above a narrow one still connects top-to-bottom. + let gap_x = (to.left() - from.right()).max(from.left() - to.right()); + let gap_y = (to.top() - from.bottom()).max(from.top() - to.bottom()); + + let horizontal = if gap_x >= 0.0 && gap_y >= 0.0 { + gap_x >= gap_y + } else if gap_x >= 0.0 { + true + } else if gap_y >= 0.0 { + false + } else { + delta.x.abs() >= delta.y.abs() + }; + + if horizontal { + if delta.x >= 0.0 { + (Side::Right, Side::Left) + } else { + (Side::Left, Side::Right) + } + } else if delta.y >= 0.0 { + (Side::Bottom, Side::Top) + } else { + (Side::Top, Side::Bottom) + } +} + +/// The four control points of the cubic Bézier used to draw an edge. +pub fn edge_curve(from: Rect, from_side: Side, to: Rect, to_side: Side) -> [Pos2; 4] { + let start = anchor(from, from_side); + let end = anchor(to, to_side); + curve_between(start, from_side, end, to_side) +} + +/// Same as [`edge_curve`] but for free endpoints (used while dragging a new edge). +pub fn curve_between(start: Pos2, from_side: Side, end: Pos2, to_side: Side) -> [Pos2; 4] { + let distance = (end - start).length(); + let strength = (distance * 0.45).clamp(30.0, 250.0); + [ + start, + start + normal(from_side) * strength, + end + normal(to_side) * strength, + end, + ] +} + +/// Point on a cubic Bézier at `t` in `0..=1`. +pub fn bezier_point(p: [Pos2; 4], t: f32) -> Pos2 { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + pos2( + a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x, + a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y, + ) +} + +/// Derivative of a cubic Bézier at `t`; the direction the curve travels in. +pub fn bezier_tangent(p: [Pos2; 4], t: f32) -> Vec2 { + let u = 1.0 - t; + let d = (p[1] - p[0]) * (3.0 * u * u) + (p[2] - p[1]) * (6.0 * u * t) + (p[3] - p[2]) * (3.0 * t * t); + if d.length() > f32::EPSILON { + d.normalized() + } else { + (p[3] - p[0]).normalized() + } +} + +/// How many samples are used when approximating a curve by a polyline. +const SAMPLES: usize = 24; + +/// Shortest distance from `point` to the curve, used for hit testing edges. +pub fn distance_to_curve(p: [Pos2; 4], point: Pos2) -> f32 { + let mut best = f32::INFINITY; + let mut previous = p[0]; + for i in 1..=SAMPLES { + let current = bezier_point(p, i as f32 / SAMPLES as f32); + best = best.min(distance_to_segment(point, previous, current)); + previous = current; + } + best +} + +/// Axis aligned bounds of the curve, approximated from samples. +pub fn curve_bounds(p: [Pos2; 4]) -> Rect { + let mut rect = Rect::from_points(&[p[0], p[3]]); + for i in 1..SAMPLES { + rect = rect.union(Rect::from_points(&[bezier_point(p, i as f32 / SAMPLES as f32)])); + } + rect +} + +fn distance_to_segment(point: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let len_sq = ab.length_sq(); + if len_sq <= f32::EPSILON { + return (point - a).length(); + } + let t = ((point - a).dot(ab) / len_sq).clamp(0.0, 1.0); + (point - (a + ab * t)).length() +} + +/// The bounding box of everything in `rects`, or `None` when there is nothing. +pub fn bounds_of(rects: impl IntoIterator) -> Option { + rects.into_iter().reduce(|acc, r| acc.union(r)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(w, h)) + } + + #[test] + fn anchors_sit_on_the_middle_of_each_side() { + let r = rect(0.0, 0.0, 100.0, 50.0); + assert_eq!(anchor(r, Side::Top), pos2(50.0, 0.0)); + assert_eq!(anchor(r, Side::Bottom), pos2(50.0, 50.0)); + assert_eq!(anchor(r, Side::Left), pos2(0.0, 25.0)); + assert_eq!(anchor(r, Side::Right), pos2(100.0, 25.0)); + } + + #[test] + fn auto_sides_follow_the_dominant_axis() { + let a = rect(0.0, 0.0, 100.0, 100.0); + assert_eq!( + auto_sides(a, rect(400.0, 0.0, 100.0, 100.0)), + (Side::Right, Side::Left) + ); + assert_eq!( + auto_sides(a, rect(-400.0, 0.0, 100.0, 100.0)), + (Side::Left, Side::Right) + ); + assert_eq!( + auto_sides(a, rect(0.0, 400.0, 100.0, 100.0)), + (Side::Bottom, Side::Top) + ); + assert_eq!( + auto_sides(a, rect(0.0, -400.0, 100.0, 100.0)), + (Side::Top, Side::Bottom) + ); + } + + #[test] + fn a_curve_starts_and_ends_on_its_anchors() { + let from = rect(0.0, 0.0, 100.0, 100.0); + let to = rect(300.0, 0.0, 100.0, 100.0); + let curve = edge_curve(from, Side::Right, to, Side::Left); + assert_eq!(bezier_point(curve, 0.0), anchor(from, Side::Right)); + assert_eq!(bezier_point(curve, 1.0), anchor(to, Side::Left)); + // Leaving a right side means travelling to the right. + assert!(bezier_tangent(curve, 0.0).x > 0.9); + } + + #[test] + fn hit_testing_measures_distance_to_the_curve() { + let curve = [ + pos2(0.0, 0.0), + pos2(50.0, 0.0), + pos2(50.0, 0.0), + pos2(100.0, 0.0), + ]; + assert!(distance_to_curve(curve, pos2(50.0, 0.0)) < 0.5); + assert!((distance_to_curve(curve, pos2(50.0, 20.0)) - 20.0).abs() < 0.5); + } + + #[test] + fn bounds_cover_every_rect() { + let all = bounds_of([rect(0.0, 0.0, 10.0, 10.0), rect(90.0, 40.0, 10.0, 10.0)]).unwrap(); + assert_eq!(all, rect(0.0, 0.0, 100.0, 50.0)); + assert!(bounds_of([]).is_none()); + } +} diff --git a/src/inspector.rs b/src/inspector.rs new file mode 100644 index 0000000..35b442c --- /dev/null +++ b/src/inspector.rs @@ -0,0 +1,817 @@ +//! The properties panel: everything about the current selection that is easier +//! to change with a widget than by dragging it on the canvas. + +use std::path::{Path, PathBuf}; + +use eframe::egui::{self, Color32, ComboBox, DragValue, RichText, TextEdit, Ui}; + +use crate::document::Document; +use crate::model::{BackgroundStyle, Color, EndShape, Node, NodeKind, Side}; +use crate::theme::{self, Palette}; +use crate::view::CanvasView; + +#[derive(Default)] +pub struct InspectorOutput { + pub status: Option, + pub open_target: Option, +} + +pub fn show( + ui: &mut Ui, + doc: &mut Document, + view: &mut CanvasView, + palette: &Palette, +) -> InspectorOutput { + let mut out = InspectorOutput::default(); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add_space(4.0); + if let Some(id) = view.selection.only_node().map(str::to_owned) { + node_section(ui, doc, view, palette, &id, &mut out); + } else if let Some(id) = view.selection.only_edge().map(str::to_owned) { + edge_section(ui, doc, view, palette, &id, &mut out); + } else if !view.selection.is_empty() { + multi_section(ui, doc, view, palette, &mut out); + } else { + canvas_section(ui, doc, palette, &mut out); + } + }); + out +} + +// --------------------------------------------------------------------------- +// A single node +// --------------------------------------------------------------------------- + +fn node_section( + ui: &mut Ui, + doc: &mut Document, + view: &mut CanvasView, + palette: &Palette, + id: &str, + out: &mut InspectorOutput, +) { + let Some(node) = doc.canvas.node(id).cloned() else { + return; + }; + heading(ui, palette, &format!("{} node", capitalise(node.kind.type_name()))); + + match &node.kind { + NodeKind::Text { text } => { + let mut buffer = text.clone(); + let response = ui.add( + TextEdit::multiline(&mut buffer) + .desired_width(f32::INFINITY) + .desired_rows(6) + .hint_text("Markdown"), + ); + if response.changed() { + doc.begin_change("Edit text"); + if let Some(NodeKind::Text { text }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { + *text = buffer; + } + } + if response.lost_focus() { + doc.end_change(); + } + } + NodeKind::File { file, subpath } => { + let mut path = file.clone(); + let mut sub = subpath.clone().unwrap_or_default(); + ui.label(field_label(palette, "File")); + let changed = ui + .add(TextEdit::singleline(&mut path).desired_width(f32::INFINITY)) + .changed(); + ui.horizontal(|ui| { + if ui.button("Browse…").clicked() + && let Some(picked) = pick_any_file(doc) + { + path = picked; + doc.change("Set file"); + set_file(doc, id, Some(path.clone()), None); + } + if ui.button("Open").on_hover_text("Open with the system default application").clicked() { + out.open_target = Some(file.clone()); + } + }); + ui.add_space(6.0); + ui.label(field_label(palette, "Subpath")); + let sub_changed = ui + .add( + TextEdit::singleline(&mut sub) + .hint_text("#heading") + .desired_width(f32::INFINITY), + ) + .changed(); + if changed || sub_changed { + doc.begin_change("Edit file node"); + set_file(doc, id, Some(path), Some(sub)); + } + } + NodeKind::Link { url } => { + let mut buffer = url.clone(); + ui.label(field_label(palette, "URL")); + if ui + .add(TextEdit::singleline(&mut buffer).desired_width(f32::INFINITY)) + .changed() + { + doc.begin_change("Edit URL"); + if let Some(NodeKind::Link { url }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { + *url = buffer; + } + } + if ui.button("Open in browser").clicked() { + out.open_target = Some(url.clone()); + } + } + NodeKind::Group { + label, + background, + background_style, + } => { + let mut label_buffer = label.clone().unwrap_or_default(); + ui.label(field_label(palette, "Label")); + if ui + .add( + TextEdit::singleline(&mut label_buffer) + .desired_width(f32::INFINITY) + .hint_text("Unnamed group"), + ) + .changed() + { + doc.begin_change("Edit label"); + set_group(doc, id, Some(label_buffer), None, None); + } + + ui.add_space(6.0); + ui.label(field_label(palette, "Background image")); + let mut background_buffer = background.clone().unwrap_or_default(); + if ui + .add(TextEdit::singleline(&mut background_buffer).desired_width(f32::INFINITY)) + .changed() + { + doc.begin_change("Edit background"); + set_group(doc, id, None, Some(background_buffer), None); + } + ui.horizontal(|ui| { + if ui.button("Browse…").clicked() + && let Some(picked) = pick_image_file(doc) + { + doc.change("Set background"); + set_group(doc, id, None, Some(picked), None); + } + if background.is_some() && ui.button("Clear").clicked() { + doc.change("Clear background"); + set_group(doc, id, None, Some(String::new()), None); + } + }); + + ui.add_space(6.0); + let mut style = *background_style; + ComboBox::from_label("Fitting") + .selected_text(style.map_or("default", BackgroundStyle::label)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut style, None, "default"); + for option in BackgroundStyle::ALL { + ui.selectable_value(&mut style, Some(option), option.label()); + } + }); + if style != *background_style { + doc.change("Set background fitting"); + set_group(doc, id, None, None, Some(style)); + } + + let members = doc.canvas.nodes_in_group(id).len(); + ui.add_space(4.0); + ui.label( + RichText::new(format!( + "Contains {members} node{}", + if members == 1 { "" } else { "s" } + )) + .size(12.0) + .color(palette.text_muted), + ); + } + NodeKind::Unknown { type_name } => { + ui.label( + RichText::new(format!( + "This node has the unsupported type “{type_name}”. Its attributes are kept as they are when saving." + )) + .size(12.0) + .color(palette.text_muted), + ); + } + } + + ui.add_space(10.0); + geometry_section(ui, doc, palette, id, &node); + + ui.add_space(10.0); + ui.label(field_label(palette, "Colour")); + if let Some(color) = color_picker(ui, node.color.as_ref(), palette, id) { + doc.change("Set colour"); + if let Some(node) = doc.canvas.node_mut(id) { + node.color = color; + } + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Bring to front").clicked() { + view.raise_selection(doc, true); + } + if ui.button("Send to back").clicked() { + view.raise_selection(doc, false); + } + }); + ui.add_space(4.0); + if ui + .button(RichText::new("Delete node").color(palette.danger)) + .clicked() + { + out.status = view.delete_selection(doc); + } + + ui.add_space(10.0); + ui.label( + RichText::new(format!("id: {}", node.id)) + .size(11.0) + .monospace() + .color(palette.text_muted), + ); +} + +fn geometry_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, id: &str, node: &Node) { + let (mut x, mut y, mut w, mut h) = (node.x, node.y, node.width, node.height); + ui.label(field_label(palette, "Position and size")); + let mut changed = false; + ui.horizontal(|ui| { + changed |= ui.add(DragValue::new(&mut x).speed(1).prefix("x ")).changed(); + changed |= ui.add(DragValue::new(&mut y).speed(1).prefix("y ")).changed(); + }); + ui.horizontal(|ui| { + changed |= ui + .add(DragValue::new(&mut w).speed(1).range(1..=100_000).prefix("w ")) + .changed(); + changed |= ui + .add(DragValue::new(&mut h).speed(1).range(1..=100_000).prefix("h ")) + .changed(); + }); + if changed { + doc.begin_change("Edit geometry"); + if let Some(node) = doc.canvas.node_mut(id) { + node.x = x; + node.y = y; + node.width = w; + node.height = h; + } + } +} + +// --------------------------------------------------------------------------- +// A single edge +// --------------------------------------------------------------------------- + +fn edge_section( + ui: &mut Ui, + doc: &mut Document, + view: &mut CanvasView, + palette: &Palette, + id: &str, + out: &mut InspectorOutput, +) { + let Some(edge) = doc.canvas.edge(id).cloned() else { + return; + }; + heading(ui, palette, "Edge"); + + let name_of = |node_id: &str| { + doc.canvas + .node(node_id) + .map(|n| n.title()) + .unwrap_or_else(|| format!("missing node {node_id}")) + }; + ui.label( + RichText::new(format!("{} → {}", name_of(&edge.from_node), name_of(&edge.to_node))) + .size(12.0) + .color(palette.text_muted), + ); + + ui.add_space(8.0); + let mut from_side = edge.from_side; + let mut to_side = edge.to_side; + let mut from_end = edge.from_end; + let mut to_end = edge.to_end; + + ui.horizontal(|ui| { + side_combo(ui, "From", &mut from_side); + end_combo(ui, "Start", &mut from_end, EndShape::None); + }); + ui.horizontal(|ui| { + side_combo(ui, "To", &mut to_side); + end_combo(ui, "End", &mut to_end, EndShape::Arrow); + }); + if from_side != edge.from_side + || to_side != edge.to_side + || from_end != edge.from_end + || to_end != edge.to_end + { + doc.change("Edit edge"); + if let Some(edge) = doc.canvas.edge_mut(id) { + edge.from_side = from_side; + edge.to_side = to_side; + edge.from_end = from_end; + edge.to_end = to_end; + } + } + + ui.add_space(8.0); + ui.label(field_label(palette, "Label")); + let mut label = edge.label.clone().unwrap_or_default(); + if ui + .add(TextEdit::singleline(&mut label).desired_width(f32::INFINITY)) + .changed() + { + doc.begin_change("Edit edge label"); + if let Some(edge) = doc.canvas.edge_mut(id) { + edge.label = (!label.is_empty()).then_some(label); + } + } + + ui.add_space(8.0); + ui.label(field_label(palette, "Colour")); + if let Some(color) = color_picker(ui, edge.color.as_ref(), palette, id) { + doc.change("Set colour"); + if let Some(edge) = doc.canvas.edge_mut(id) { + edge.color = color; + } + } + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Reverse direction").clicked() { + doc.change("Reverse edge"); + if let Some(edge) = doc.canvas.edge_mut(id) { + std::mem::swap(&mut edge.from_node, &mut edge.to_node); + std::mem::swap(&mut edge.from_side, &mut edge.to_side); + std::mem::swap(&mut edge.from_end, &mut edge.to_end); + } + out.status = Some("Reversed the edge".to_owned()); + } + if ui + .button(RichText::new("Delete edge").color(palette.danger)) + .clicked() + { + out.status = view.delete_selection(doc); + } + }); +} + +fn side_combo(ui: &mut Ui, label: &str, side: &mut Option) { + ComboBox::from_id_salt(label) + .selected_text(format!( + "{label}: {}", + side.map_or("auto", Side::label) + )) + .width(110.0) + .show_ui(ui, |ui| { + ui.selectable_value(side, None, "auto"); + for option in Side::ALL { + ui.selectable_value(side, Some(option), option.label()); + } + }); +} + +fn end_combo(ui: &mut Ui, label: &str, end: &mut Option, default: EndShape) { + let text = match end { + None => format!("default ({})", end_name(default)), + Some(value) => end_name(*value).to_owned(), + }; + ComboBox::from_id_salt(label) + .selected_text(format!("{label}: {text}")) + .width(130.0) + .show_ui(ui, |ui| { + ui.selectable_value(end, None, format!("default ({})", end_name(default))); + ui.selectable_value(end, Some(EndShape::None), "none"); + ui.selectable_value(end, Some(EndShape::Arrow), "arrow"); + }); +} + +fn end_name(end: EndShape) -> &'static str { + match end { + EndShape::None => "none", + EndShape::Arrow => "arrow", + } +} + +// --------------------------------------------------------------------------- +// Many things selected +// --------------------------------------------------------------------------- + +fn multi_section( + ui: &mut Ui, + doc: &mut Document, + view: &mut CanvasView, + palette: &Palette, + out: &mut InspectorOutput, +) { + let nodes = view.selection.nodes.len(); + let edges = view.selection.edges.len(); + heading(ui, palette, "Multiple items"); + ui.label( + RichText::new(format!( + "{nodes} node{} and {edges} edge{} selected", + plural(nodes), + plural(edges) + )) + .size(12.0) + .color(palette.text_muted), + ); + + ui.add_space(10.0); + ui.label(field_label(palette, "Colour")); + if let Some(color) = color_picker(ui, None, palette, "multi") { + doc.change("Set colour"); + for id in view.selection.nodes.clone() { + if let Some(node) = doc.canvas.node_mut(&id) { + node.color = color.clone(); + } + } + for id in view.selection.edges.clone() { + if let Some(edge) = doc.canvas.edge_mut(&id) { + edge.color = color.clone(); + } + } + } + + if nodes >= 2 { + ui.add_space(10.0); + ui.label(field_label(palette, "Align")); + ui.horizontal(|ui| { + if ui.button("Left").clicked() { + align(doc, view, Align::Left); + } + if ui.button("Centre").clicked() { + align(doc, view, Align::CentreX); + } + if ui.button("Right").clicked() { + align(doc, view, Align::Right); + } + }); + ui.horizontal(|ui| { + if ui.button("Top").clicked() { + align(doc, view, Align::Top); + } + if ui.button("Middle").clicked() { + align(doc, view, Align::CentreY); + } + if ui.button("Bottom").clicked() { + align(doc, view, Align::Bottom); + } + }); + } + + ui.add_space(10.0); + if ui + .button(RichText::new("Delete selection").color(palette.danger)) + .clicked() + { + out.status = view.delete_selection(doc); + } +} + +#[derive(Clone, Copy)] +enum Align { + Left, + CentreX, + Right, + Top, + CentreY, + Bottom, +} + +fn align(doc: &mut Document, view: &CanvasView, how: Align) { + let ids = view.selection.nodes.clone(); + let boxes: Vec<(i32, i32, i32, i32)> = ids + .iter() + .filter_map(|id| doc.canvas.node(id)) + .map(|n| (n.x, n.y, n.width, n.height)) + .collect(); + if boxes.len() < 2 { + return; + } + let left = boxes.iter().map(|b| b.0).min().unwrap_or(0); + let right = boxes.iter().map(|b| b.0 + b.2).max().unwrap_or(0); + let top = boxes.iter().map(|b| b.1).min().unwrap_or(0); + let bottom = boxes.iter().map(|b| b.1 + b.3).max().unwrap_or(0); + + doc.change("Align"); + for id in ids { + let Some(node) = doc.canvas.node_mut(&id) else { + continue; + }; + match how { + Align::Left => node.x = left, + Align::Right => node.x = right - node.width, + Align::CentreX => node.x = (left + right) / 2 - node.width / 2, + Align::Top => node.y = top, + Align::Bottom => node.y = bottom - node.height, + Align::CentreY => node.y = (top + bottom) / 2 - node.height / 2, + } + } +} + +// --------------------------------------------------------------------------- +// Nothing selected +// --------------------------------------------------------------------------- + +fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut InspectorOutput) { + heading(ui, palette, "Canvas"); + let nodes = doc.canvas.nodes.len(); + let edges = doc.canvas.edges.len(); + let groups = doc.canvas.nodes.iter().filter(|n| n.is_group()).count(); + for (label, value) in [ + ("Nodes", nodes.to_string()), + ("Groups", groups.to_string()), + ("Edges", edges.to_string()), + ( + "File", + doc.path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "not saved yet".to_owned()), + ), + ] { + ui.horizontal(|ui| { + ui.label(field_label(palette, label)); + ui.label(RichText::new(value).size(12.0)); + }); + } + + let dangling = doc + .canvas + .edges + .iter() + .filter(|e| { + doc.canvas.node(&e.from_node).is_none() || doc.canvas.node(&e.to_node).is_none() + }) + .count(); + if dangling > 0 { + ui.add_space(8.0); + ui.label( + RichText::new(format!( + "{dangling} edge{} point at nodes that do not exist.", + plural(dangling) + )) + .size(12.0) + .color(palette.danger), + ); + if ui.button("Remove them").clicked() { + doc.change("Remove dangling edges"); + let removed = doc.canvas.drop_dangling_edges(); + out.status = Some(format!("Removed {removed} dangling edge{}", plural(removed))); + } + } + + ui.add_space(12.0); + ui.label(field_label(palette, "Tips")); + for tip in [ + "Double-click the canvas to add a note", + "Drag a dot on a node's edge to connect it", + "Middle- or right-drag to pan, Ctrl+wheel to zoom", + "Hold Shift to add to the selection", + ] { + ui.label(RichText::new(format!("• {tip}")).size(12.0).color(palette.text_muted)); + } +} + +// --------------------------------------------------------------------------- +// Shared widgets +// --------------------------------------------------------------------------- + +/// A row of the six preset colours plus a hex field. Returns the new value when +/// the user picked one. +fn color_picker( + ui: &mut Ui, + current: Option<&Color>, + palette: &Palette, + salt: &str, +) -> Option> { + let mut picked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (index, color) in theme::PRESETS.iter().enumerate() { + let preset = Color::Preset(index as u8 + 1); + let selected = current == Some(&preset); + if swatch(ui, *color, selected, palette).clicked() { + picked = Some(Some(preset)); + } + } + if swatch(ui, palette.surface, current.is_none(), palette) + .on_hover_text("No colour") + .clicked() + { + picked = Some(None); + } + }); + + ui.add_space(4.0); + ui.horizontal(|ui| { + let mut hex = match current { + Some(Color::Hex(hex)) => hex.clone(), + _ => String::new(), + }; + let response = ui.add( + TextEdit::singleline(&mut hex) + .id_salt(format!("hex-{salt}")) + .hint_text("#rrggbb") + .desired_width(90.0), + ); + if response.changed() + && let Some(color) = Color::parse(&hex) + { + picked = Some(Some(color)); + } + if let Some(color) = current { + ui.label( + RichText::new(color.display_name()) + .size(11.0) + .color(palette.text_muted), + ); + } + }); + picked +} + +fn swatch(ui: &mut Ui, color: Color32, selected: bool, palette: &Palette) -> egui::Response { + let size = egui::vec2(20.0, 20.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + let stroke = if selected { + egui::Stroke::new(2.0, palette.accent) + } else if response.hovered() { + egui::Stroke::new(1.5, palette.text_muted) + } else { + egui::Stroke::new(1.0, palette.border) + }; + ui.painter().rect( + rect, + egui::CornerRadius::same(4), + color, + stroke, + egui::StrokeKind::Inside, + ); + response +} + +fn heading(ui: &mut Ui, palette: &Palette, text: &str) { + ui.label(RichText::new(text).size(15.0).strong().color(palette.text)); + ui.add_space(6.0); +} + +fn field_label(palette: &Palette, text: &str) -> RichText { + RichText::new(text).size(11.0).color(palette.text_muted) +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} + +fn capitalise(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +fn set_file(doc: &mut Document, id: &str, file: Option, subpath: Option) { + if let Some(NodeKind::File { + file: current, + subpath: current_sub, + }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + if let Some(file) = file { + *current = file; + } + if let Some(sub) = subpath { + *current_sub = (!sub.is_empty()).then_some(sub); + } + } +} + +fn set_group( + doc: &mut Document, + id: &str, + label: Option, + background: Option, + style: Option>, +) { + if let Some(NodeKind::Group { + label: current_label, + background: current_background, + background_style, + }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + if let Some(label) = label { + *current_label = (!label.is_empty()).then_some(label); + } + if let Some(background) = background { + *current_background = (!background.is_empty()).then_some(background); + } + if let Some(style) = style { + *background_style = style; + } + } +} + +fn pick_any_file(doc: &Document) -> Option { + let mut dialog = rfd::FileDialog::new(); + if let Some(dir) = doc.base_dir() { + dialog = dialog.set_directory(dir); + } + dialog + .pick_file() + .map(|path| relative_to(doc.base_dir().as_deref(), &path)) +} + +fn pick_image_file(doc: &Document) -> Option { + let mut dialog = rfd::FileDialog::new().add_filter( + "Images", + &["png", "jpg", "jpeg", "gif", "bmp", "webp"], + ); + if let Some(dir) = doc.base_dir() { + dialog = dialog.set_directory(dir); + } + dialog + .pick_file() + .map(|path| relative_to(doc.base_dir().as_deref(), &path)) +} + +/// Paths are stored relative to the canvas when possible, which is what makes a +/// canvas plus its files portable. +pub fn relative_to(base: Option<&Path>, path: &Path) -> String { + let relative = base + .and_then(|base| path.strip_prefix(base).ok()) + .map(PathBuf::from); + let path = relative.as_deref().unwrap_or(path); + path.to_string_lossy().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::NodeKind; + use crate::view::CanvasView; + + fn node(id: &str, x: i32, y: i32, w: i32, h: i32) -> Node { + Node::new(id, NodeKind::Text { text: String::new() }, x, y, w, h) + } + + #[test] + fn relative_paths_are_used_when_the_file_is_below_the_canvas() { + let base = Path::new("/vault"); + assert_eq!( + relative_to(Some(base), Path::new("/vault/img/a.png")), + "img/a.png" + ); + assert_eq!( + relative_to(Some(base), Path::new("/elsewhere/a.png")), + "/elsewhere/a.png" + ); + assert_eq!(relative_to(None, Path::new("/a/b.png")), "/a/b.png"); + } + + #[test] + fn aligning_moves_nodes_to_a_shared_edge() { + let mut doc = Document::new(); + doc.canvas.nodes.push(node("a", 0, 0, 100, 100)); + doc.canvas.nodes.push(node("b", 50, 200, 200, 50)); + let mut view = CanvasView::new(); + view.select_all(&doc.canvas); + + align(&mut doc, &view, Align::Left); + assert_eq!(doc.canvas.node("b").unwrap().x, 0); + + align(&mut doc, &view, Align::Right); + assert_eq!(doc.canvas.node("a").unwrap().x, 100); + assert_eq!(doc.canvas.node("b").unwrap().x, 0); + + align(&mut doc, &view, Align::Top); + assert_eq!(doc.canvas.node("b").unwrap().y, 0); + } + + #[test] + fn aligning_needs_at_least_two_nodes() { + let mut doc = Document::new(); + doc.canvas.nodes.push(node("a", 7, 7, 100, 100)); + let mut view = CanvasView::new(); + view.select_node("a"); + align(&mut doc, &view, Align::Left); + assert_eq!(doc.canvas.node("a").unwrap().x, 7); + assert!(!doc.can_undo(), "a no-op should not create an undo step"); + } + + #[test] + fn capitalises_type_names_for_the_heading() { + assert_eq!(capitalise("text"), "Text"); + assert_eq!(capitalise(""), ""); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..70174b7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +//! A native editor for the JSON Canvas file format (). +//! +//! Usage: `jsoncanvas-desktop [FILE.canvas]` + +// Do not pop up a console window next to the app on Windows release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod document; +mod geometry; +mod inspector; +mod markdown; +mod model; +mod nodes; +mod theme; +mod view; + +use std::path::PathBuf; + +fn main() -> eframe::Result { + let mut args = std::env::args_os().skip(1); + let open = match args.next() { + Some(arg) if arg == "--help" || arg == "-h" => { + println!("Usage: jsoncanvas-desktop [FILE.canvas]"); + return Ok(()); + } + Some(arg) => Some(PathBuf::from(arg)), + None => None, + }; + + let options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_title("JSON Canvas Editor") + .with_inner_size([1280.0, 820.0]) + .with_min_inner_size([640.0, 420.0]) + .with_app_id("jsoncanvas-desktop"), + ..Default::default() + }; + + eframe::run_native( + "JSON Canvas Editor", + options, + Box::new(|cc| Ok(Box::new(app::App::new(cc, open)))), + ) +} diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..1c45475 --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,750 @@ +//! A small Markdown subset renderer for the contents of `text` nodes. +//! +//! The spec says a text node holds Markdown but does not pin down a flavour, so +//! this covers the parts that actually show up on canvases: headings, emphasis, +//! inline code, fenced code, quotes, bullet/numbered/task lists, rules, links +//! and images. Anything it does not recognise is rendered as plain text. + +use eframe::egui::{self, Color32, RichText, Ui}; + +use crate::theme::Palette; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Style { + pub bold: bool, + pub italic: bool, + pub code: bool, + pub strike: bool, +} + +/// A run of text sharing one style, optionally a link. +#[derive(Debug, Clone, PartialEq)] +pub struct Span { + pub text: String, + pub style: Style, + /// Link target (`[text](target)`), or the URL of an autolink. + pub link: Option, + /// `true` for `![alt](src)`. + pub image: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Marker { + Bullet, + Number(u64), + Task(bool), +} + +/// One cell of a table. +pub type Cell = Vec; + +#[derive(Debug, Clone, PartialEq)] +pub enum Block { + Heading { level: u8, spans: Vec }, + Paragraph(Vec), + List { indent: usize, marker: Marker, spans: Vec }, + Quote(Vec), + Code { language: String, text: String }, + Table { header: Vec, rows: Vec> }, + Rule, +} + +// --------------------------------------------------------------------------- +// Block parsing +// --------------------------------------------------------------------------- + +/// Splits Markdown source into renderable blocks. +pub fn parse(source: &str) -> Vec { + let mut blocks = Vec::new(); + let mut paragraph: Vec = Vec::new(); + let mut quote: Vec = Vec::new(); + let mut lines = source.lines().peekable(); + + fn flush(buffer: &mut Vec, blocks: &mut Vec, quoted: bool) { + if buffer.is_empty() { + return; + } + let text = buffer.join(" "); + buffer.clear(); + let spans = parse_inline(&text); + blocks.push(if quoted { + Block::Quote(spans) + } else { + Block::Paragraph(spans) + }); + } + + while let Some(line) = lines.next() { + let trimmed = line.trim(); + + if let Some(fence) = fence_of(trimmed) { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + let language = trimmed[fence.len()..].trim().to_owned(); + let mut body = Vec::new(); + for line in lines.by_ref() { + if line.trim().starts_with(fence) { + break; + } + body.push(line.to_owned()); + } + blocks.push(Block::Code { + language, + text: body.join("\n"), + }); + continue; + } + + if trimmed.is_empty() { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + continue; + } + + if is_rule(trimmed) { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + blocks.push(Block::Rule); + continue; + } + + if let Some((level, rest)) = heading_of(trimmed) { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + blocks.push(Block::Heading { + level, + spans: parse_inline(rest), + }); + continue; + } + + if let Some(rest) = trimmed.strip_prefix('>') { + flush(&mut paragraph, &mut blocks, false); + quote.push(rest.trim_start().to_owned()); + continue; + } + + // A pipe table: a header row, a `| --- |` separator, then the body. + if trimmed.contains('|') + && lines.peek().is_some_and(|next| is_separator_row(next)) + { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + let header = split_row(trimmed); + lines.next(); + let mut rows = Vec::new(); + while let Some(row) = lines.peek() { + if !row.contains('|') || row.trim().is_empty() { + break; + } + rows.push(split_row(row.trim())); + lines.next(); + } + blocks.push(Block::Table { header, rows }); + continue; + } + + if let Some((indent, marker, rest)) = list_item_of(line) { + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + blocks.push(Block::List { + indent, + marker, + spans: parse_inline(rest), + }); + continue; + } + + flush(&mut quote, &mut blocks, true); + paragraph.push(trimmed.to_owned()); + } + + flush(&mut paragraph, &mut blocks, false); + flush(&mut quote, &mut blocks, true); + blocks +} + +fn fence_of(line: &str) -> Option<&'static str> { + if line.starts_with("```") { + Some("```") + } else if line.starts_with("~~~") { + Some("~~~") + } else { + None + } +} + +fn is_rule(line: &str) -> bool { + let compact: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + compact.len() >= 3 + && (compact.chars().all(|c| c == '-') + || compact.chars().all(|c| c == '*') + || compact.chars().all(|c| c == '_')) +} + +/// `| --- | :--: |`, the line that turns the row above it into a table header. +fn is_separator_row(line: &str) -> bool { + let line = line.trim(); + if !line.contains('|') { + return false; + } + let cells = split_cells(line); + !cells.is_empty() + && cells.iter().all(|cell| { + let body = cell.trim().trim_start_matches(':').trim_end_matches(':'); + !body.is_empty() && body.chars().all(|c| c == '-') + }) +} + +fn split_cells(line: &str) -> Vec<&str> { + line.trim() + .trim_start_matches('|') + .trim_end_matches('|') + .split('|') + .map(str::trim) + .collect() +} + +fn split_row(line: &str) -> Vec { + split_cells(line).into_iter().map(parse_inline).collect() +} + +fn heading_of(line: &str) -> Option<(u8, &str)> { + let hashes = line.chars().take_while(|c| *c == '#').count(); + if (1..=6).contains(&hashes) { + let rest = &line[hashes..]; + // ATX headings need a space after the hashes. + rest.strip_prefix(' ').map(|rest| (hashes as u8, rest.trim())) + } else { + None + } +} + +fn list_item_of(line: &str) -> Option<(usize, Marker, &str)> { + let indent_chars = line.len() - line.trim_start().len(); + let indent = indent_chars / 2; + let trimmed = line.trim_start(); + + let (marker, rest) = if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + let rest_trimmed = rest.trim_start(); + let lowered = rest_trimmed.to_ascii_lowercase(); + if let Some(task) = lowered.strip_prefix("[ ] ") { + (Marker::Task(false), &rest_trimmed[rest_trimmed.len() - task.len()..]) + } else if let Some(task) = lowered + .strip_prefix("[x] ") + .or_else(|| lowered.strip_prefix("[/] ")) + { + (Marker::Task(true), &rest_trimmed[rest_trimmed.len() - task.len()..]) + } else { + (Marker::Bullet, rest) + } + } else { + let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() { + return None; + } + let rest = &trimmed[digits.len()..]; + let rest = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") "))?; + (Marker::Number(digits.parse().ok()?), rest) + }; + + Some((indent, marker, rest.trim_start())) +} + +// --------------------------------------------------------------------------- +// Inline parsing +// --------------------------------------------------------------------------- + +/// Parses emphasis, code, links and images inside one block of text. +pub fn parse_inline(text: &str) -> Vec { + let mut spans = Vec::new(); + parse_inline_into(text, Style::default(), &mut spans); + spans.retain(|s| !s.text.is_empty()); + spans +} + +fn parse_inline_into(text: &str, style: Style, out: &mut Vec) { + let mut plain = String::new(); + let mut rest = text; + + macro_rules! flush { + () => { + if !plain.is_empty() { + out.push(Span { + text: std::mem::take(&mut plain), + style, + link: None, + image: false, + }); + } + }; + } + + while !rest.is_empty() { + // Backslash escapes. + if let Some(tail) = rest.strip_prefix('\\') { + let mut chars = tail.chars(); + if let Some(escaped) = chars.next() { + plain.push(escaped); + rest = chars.as_str(); + continue; + } + } + + // Inline code takes precedence and is never parsed further. + if let Some((inner, tail)) = delimited(rest, "`") { + flush!(); + out.push(Span { + text: inner.to_owned(), + style: Style { code: true, ..style }, + link: None, + image: false, + }); + rest = tail; + continue; + } + + if let Some((inner, tail)) = delimited(rest, "**").or_else(|| delimited(rest, "__")) { + flush!(); + parse_inline_into(inner, Style { bold: true, ..style }, out); + rest = tail; + continue; + } + + if let Some((inner, tail)) = delimited(rest, "~~") { + flush!(); + parse_inline_into(inner, Style { strike: true, ..style }, out); + rest = tail; + continue; + } + + if let Some((inner, tail)) = delimited(rest, "*").or_else(|| delimited(rest, "_")) { + flush!(); + parse_inline_into(inner, Style { italic: true, ..style }, out); + rest = tail; + continue; + } + + if let Some((label, target, tail, image)) = link_at(rest) { + flush!(); + let mut link_spans = Vec::new(); + parse_inline_into(label, style, &mut link_spans); + if link_spans.is_empty() { + link_spans.push(Span { + text: target.clone(), + style, + link: None, + image: false, + }); + } + for mut span in link_spans { + span.link = Some(target.clone()); + span.image = image; + out.push(span); + } + rest = tail; + continue; + } + + if let Some((url, tail)) = autolink_at(rest) { + flush!(); + out.push(Span { + text: url.clone(), + style, + link: Some(url), + image: false, + }); + rest = tail; + continue; + } + + let mut chars = rest.chars(); + if let Some(c) = chars.next() { + plain.push(c); + } + rest = chars.as_str(); + } + + flush!(); +} + +/// If `text` opens with `delim`, returns the content up to the matching `delim` +/// and the remainder after it. +fn delimited<'a>(text: &'a str, delim: &str) -> Option<(&'a str, &'a str)> { + let after_open = text.strip_prefix(delim)?; + // `**bold**` must not be mistaken for the start of `*italic*`. + if delim.len() == 1 && after_open.starts_with(delim) { + return None; + } + let end = after_open.find(delim)?; + let inner = &after_open[..end]; + if inner.is_empty() || inner.starts_with(char::is_whitespace) { + return None; + } + Some((inner, &after_open[end + delim.len()..])) +} + +/// Parses `[label](target)` or `![alt](src)` at the start of `text`. +fn link_at(text: &str) -> Option<(&str, String, &str, bool)> { + let (image, body) = match text.strip_prefix('!') { + Some(body) if body.starts_with('[') => (true, body), + _ => (false, text), + }; + let after_open = body.strip_prefix('[')?; + let close = after_open.find("](")?; + let label = &after_open[..close]; + let after_label = &after_open[close + 2..]; + let end = after_label.find(')')?; + let target = after_label[..end].trim().to_owned(); + if target.is_empty() { + return None; + } + Some((label, target, &after_label[end + 1..], image)) +} + +/// Parses a bare `http(s)://…` URL at the start of `text`. +fn autolink_at(text: &str) -> Option<(String, &str)> { + if !(text.starts_with("http://") || text.starts_with("https://")) { + return None; + } + let end = text + .find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '>' | '"')) + .unwrap_or(text.len()); + let url = text[..end].trim_end_matches(['.', ',', ';', ':', '!']); + Some((url.to_owned(), &text[url.len()..])) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const HEADING_SCALE: [f32; 6] = [1.7, 1.45, 1.25, 1.12, 1.0, 0.95]; + +/// Draws the blocks and returns the target of a link the user clicked. +pub fn render(ui: &mut Ui, blocks: &[Block], palette: &Palette, base_size: f32) -> Option { + let mut activated = None; + let mut table_index = 0usize; + ui.spacing_mut().item_spacing.y = base_size * 0.35; + + for block in blocks { + match block { + Block::Heading { level, spans } => { + let size = base_size * HEADING_SCALE[(*level as usize - 1).min(5)]; + if let Some(link) = spans_ui(ui, spans, palette, size, true) { + activated = Some(link); + } + } + Block::Paragraph(spans) => { + if let Some(link) = spans_ui(ui, spans, palette, base_size, false) { + activated = Some(link); + } + } + Block::List { + indent, + marker, + spans, + } => { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(base_size * (0.4 + 1.2 * *indent as f32)); + let bullet = match marker { + Marker::Bullet => "• ".to_owned(), + Marker::Number(n) => format!("{n}. "), + Marker::Task(true) => "☑ ".to_owned(), + Marker::Task(false) => "☐ ".to_owned(), + }; + ui.label( + RichText::new(bullet) + .size(base_size) + .color(palette.text_muted), + ); + if let Some(link) = spans_inline(ui, spans, palette, base_size, false) { + activated = Some(link); + } + }); + } + Block::Quote(spans) => { + let stripe = palette.text_muted.gamma_multiply(0.6); + egui::Frame::NONE + .inner_margin(egui::Margin { + left: (base_size * 0.7) as i8, + ..Default::default() + }) + .show(ui, |ui| { + let top = ui.cursor().min; + let result = spans_ui(ui, spans, palette, base_size, false); + let bottom = ui.min_rect().max.y; + let x = top.x - base_size * 0.45; + ui.painter().line_segment( + [egui::pos2(x, top.y), egui::pos2(x, bottom)], + egui::Stroke::new(2.0, stripe), + ); + if let Some(link) = result { + activated = Some(link); + } + }); + } + Block::Code { text, .. } => { + egui::Frame::NONE + .fill(palette.surface_muted) + .corner_radius(4.0) + .inner_margin(egui::Margin::symmetric( + (base_size * 0.5) as i8, + (base_size * 0.35) as i8, + )) + .show(ui, |ui| { + ui.label( + RichText::new(text) + .monospace() + .size(base_size * 0.92) + .color(palette.text), + ); + }); + } + Block::Table { header, rows } => { + table_index += 1; + egui::Grid::new(("markdown-table", ui.id(), table_index)) + .striped(true) + .spacing([base_size * 0.8, base_size * 0.25]) + .show(ui, |ui| { + for cell in header { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + spans_inline(ui, cell, palette, base_size, true); + }); + } + ui.end_row(); + for row in rows { + for cell in row { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if let Some(link) = + spans_inline(ui, cell, palette, base_size, false) + { + activated = Some(link); + } + }); + } + ui.end_row(); + } + }); + } + Block::Rule => { + let stroke = egui::Stroke::new(1.0, palette.border); + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(width, base_size * 0.6), + egui::Sense::hover(), + ); + ui.painter().line_segment( + [ + egui::pos2(rect.left(), rect.center().y), + egui::pos2(rect.right(), rect.center().y), + ], + stroke, + ); + } + } + } + activated +} + +fn spans_ui( + ui: &mut Ui, + spans: &[Span], + palette: &Palette, + size: f32, + heading: bool, +) -> Option { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + spans_inline(ui, spans, palette, size, heading) + }) + .inner +} + +fn spans_inline( + ui: &mut Ui, + spans: &[Span], + palette: &Palette, + size: f32, + heading: bool, +) -> Option { + let mut activated = None; + for span in spans { + let mut text = RichText::new(&span.text).size(size); + if span.style.bold || heading { + text = text.strong(); + } + if span.style.italic { + text = text.italics(); + } + if span.style.strike { + text = text.strikethrough(); + } + if span.style.code { + text = text + .monospace() + .size(size * 0.92) + .background_color(palette.surface_muted); + } + + match &span.link { + Some(target) => { + let label = if span.image { + RichText::new(format!("[image] {}", span.text)).size(size) + } else { + text + } + .color(link_color(palette)); + if ui.link(label).on_hover_text(target).clicked() { + activated = Some(target.clone()); + } + } + None => { + ui.label(text.color(palette.text)); + } + } + } + activated +} + +fn link_color(palette: &Palette) -> Color32 { + palette.accent +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_headings_and_paragraphs() { + let blocks = parse("# Title\n\nSome text\nwrapped over lines.\n"); + assert_eq!(blocks.len(), 2); + assert!(matches!(blocks[0], Block::Heading { level: 1, .. })); + let Block::Paragraph(spans) = &blocks[1] else { + panic!("expected a paragraph"); + }; + assert_eq!(spans[0].text, "Some text wrapped over lines."); + } + + #[test] + fn hash_without_a_space_is_not_a_heading() { + assert!(matches!(parse("#tag")[0], Block::Paragraph(_))); + } + + #[test] + fn parses_emphasis() { + let spans = parse_inline("a **bold** and *italic* and `code` and ~~gone~~"); + let styled: Vec<_> = spans + .iter() + .map(|s| (s.text.as_str(), s.style)) + .filter(|(_, style)| *style != Style::default()) + .collect(); + assert_eq!(styled[0].0, "bold"); + assert!(styled[0].1.bold); + assert!(styled[1].1.italic); + assert!(styled[2].1.code); + assert!(styled[3].1.strike); + } + + #[test] + fn parses_links_images_and_autolinks() { + let spans = parse_inline("see [the spec](https://jsoncanvas.org) now"); + let link = spans.iter().find(|s| s.link.is_some()).unwrap(); + assert_eq!(link.text, "the spec"); + assert_eq!(link.link.as_deref(), Some("https://jsoncanvas.org")); + + let spans = parse_inline("![a picture](img/pic.png)"); + assert!(spans[0].image); + assert_eq!(spans[0].link.as_deref(), Some("img/pic.png")); + + let spans = parse_inline("go to https://example.com."); + let link = spans.iter().find(|s| s.link.is_some()).unwrap(); + assert_eq!(link.link.as_deref(), Some("https://example.com")); + } + + #[test] + fn parses_lists_including_tasks() { + let blocks = parse("- one\n- [ ] todo\n- [x] done\n1. first\n - nested"); + let markers: Vec = blocks + .iter() + .filter_map(|b| match b { + Block::List { marker, .. } => Some(*marker), + _ => None, + }) + .collect(); + assert_eq!( + markers, + vec![ + Marker::Bullet, + Marker::Task(false), + Marker::Task(true), + Marker::Number(1), + Marker::Bullet + ] + ); + let Block::List { indent, spans, .. } = &blocks[4] else { + panic!("expected a list item"); + }; + assert_eq!(*indent, 1); + assert_eq!(spans[0].text, "nested"); + } + + #[test] + fn parses_fenced_code_and_rules() { + let blocks = parse("---\n```rust\nlet x = 1;\n```\n"); + assert!(matches!(blocks[0], Block::Rule)); + let Block::Code { language, text } = &blocks[1] else { + panic!("expected code"); + }; + assert_eq!(language, "rust"); + assert_eq!(text, "let x = 1;"); + } + + #[test] + fn parses_pipe_tables() { + let blocks = parse("| Key | What it does |\n| --- | :---: |\n| `a` | first |\n| b | second |\n\nafter"); + let Block::Table { header, rows } = &blocks[0] else { + panic!("expected a table, got {blocks:?}"); + }; + assert_eq!(header.len(), 2); + assert_eq!(header[1][0].text, "What it does"); + assert_eq!(rows.len(), 2); + assert!(rows[0][0][0].style.code); + assert_eq!(rows[1][1][0].text, "second"); + // The paragraph after the table is still its own block. + assert!(matches!(blocks[1], Block::Paragraph(_))); + } + + #[test] + fn a_lone_pipe_is_not_a_table() { + assert!(matches!(parse("a | b")[0], Block::Paragraph(_))); + assert!(!is_separator_row("| not a separator |")); + assert!(is_separator_row("|---|:--:|")); + } + + #[test] + fn parses_quotes() { + let blocks = parse("> quoted\n> again"); + let Block::Quote(spans) = &blocks[0] else { + panic!("expected a quote"); + }; + assert_eq!(spans[0].text, "quoted again"); + } + + #[test] + fn escapes_are_honoured() { + let spans = parse_inline("literal \\*stars\\*"); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].text, "literal *stars*"); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..f29144b --- /dev/null +++ b/src/model.rs @@ -0,0 +1,755 @@ +//! Data model for the [JSON Canvas 1.0](https://jsoncanvas.org/spec/1.0/) file format. +//! +//! The model is deliberately lenient when reading and strict when writing: +//! +//! * unknown top-level keys, unknown node/edge attributes and even unknown node +//! `type`s are preserved verbatim so that round-tripping a file written by +//! another application never loses data, +//! * optional attributes that were absent stay absent when the file is written back. +//! +//! This module has no UI dependency so that it can be tested headless. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value}; + +/// Unrecognised attributes of a canvas, node or edge, kept for round-tripping. +pub type Extra = Map; + +/// Smallest width/height the editor will produce for a node. +pub const MIN_NODE_SIZE: i32 = 20; + +// --------------------------------------------------------------------------- +// Small enums +// --------------------------------------------------------------------------- + +/// The side of a node an edge attaches to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Top, + Right, + Bottom, + Left, +} + +impl Side { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + pub fn label(self) -> &'static str { + match self { + Self::Top => "top", + Self::Right => "right", + Self::Bottom => "bottom", + Self::Left => "left", + } + } + + pub fn opposite(self) -> Self { + match self { + Self::Top => Self::Bottom, + Self::Bottom => Self::Top, + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } +} + +/// The shape drawn at an end of an edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EndShape { + None, + Arrow, +} + +/// How a group's background image is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BackgroundStyle { + Cover, + Ratio, + Repeat, +} + +impl BackgroundStyle { + pub const ALL: [Self; 3] = [Self::Cover, Self::Ratio, Self::Repeat]; + + pub fn label(self) -> &'static str { + match self { + Self::Cover => "cover", + Self::Ratio => "ratio", + Self::Repeat => "repeat", + } + } +} + +// --------------------------------------------------------------------------- +// Color +// --------------------------------------------------------------------------- + +/// The spec's `canvasColor`: either one of the six presets or a hex string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Color { + /// Preset `"1"`..=`"6"` (red, orange, yellow, green, cyan, purple). + Preset(u8), + /// A `#RGB` or `#RRGGBB` string, stored including the leading `#`. + Hex(String), +} + +impl Color { + pub const PRESET_NAMES: [&'static str; 6] = + ["red", "orange", "yellow", "green", "cyan", "purple"]; + + /// Parses a `canvasColor`. Returns `None` for values the spec does not allow. + pub fn parse(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(hex) = raw.strip_prefix('#') { + let ok = matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit()); + return ok.then(|| Self::Hex(format!("#{}", hex.to_ascii_lowercase()))); + } + match raw.parse::() { + Ok(n @ 1..=6) => Some(Self::Preset(n)), + _ => None, + } + } + + /// The value as it appears in the file. + pub fn as_string(&self) -> String { + match self { + Self::Preset(n) => n.to_string(), + Self::Hex(hex) => hex.clone(), + } + } + + /// Human readable name, for the inspector. + pub fn display_name(&self) -> String { + match self { + Self::Preset(n) => Self::PRESET_NAMES[(*n as usize) - 1].to_owned(), + Self::Hex(hex) => hex.to_ascii_uppercase(), + } + } +} + +impl Serialize for Color { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.as_string()) + } +} + +impl<'de> Deserialize<'de> for Color { + fn deserialize>(d: D) -> Result { + // Tolerate `"color": 1` even though the spec asks for a string. + let raw = match Value::deserialize(d)? { + Value::String(s) => s, + Value::Number(n) => n.to_string(), + other => return Err(D::Error::custom(format!("invalid canvasColor: {other}"))), + }; + Color::parse(&raw).ok_or_else(|| D::Error::custom(format!("invalid canvasColor: {raw:?}"))) + } +} + +// --------------------------------------------------------------------------- +// Nodes +// --------------------------------------------------------------------------- + +/// The type-specific part of a node. +#[derive(Debug, Clone, PartialEq)] +pub enum NodeKind { + Text { + text: String, + }, + File { + file: String, + subpath: Option, + }, + Link { + url: String, + }, + Group { + label: Option, + background: Option, + background_style: Option, + }, + /// A node whose `type` this version of the spec does not know. Everything + /// about it lives in [`Node::extra`] and is written back unchanged. + Unknown { + type_name: String, + }, +} + +impl NodeKind { + pub fn type_name(&self) -> &str { + match self { + Self::Text { .. } => "text", + Self::File { .. } => "file", + Self::Link { .. } => "link", + Self::Group { .. } => "group", + Self::Unknown { type_name } => type_name, + } + } +} + +/// A node of the canvas. +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub id: String, + pub kind: NodeKind, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub color: Option, + pub extra: Extra, +} + +impl Node { + pub fn new(id: impl Into, kind: NodeKind, x: i32, y: i32, w: i32, h: i32) -> Self { + Self { + id: id.into(), + kind, + x, + y, + width: w.max(MIN_NODE_SIZE), + height: h.max(MIN_NODE_SIZE), + color: None, + extra: Extra::new(), + } + } + + pub fn is_group(&self) -> bool { + matches!(self.kind, NodeKind::Group { .. }) + } + + /// `true` when this node's box fully contains `other`'s box. + pub fn contains(&self, other: &Self) -> bool { + other.x >= self.x + && other.y >= self.y + && other.x + other.width <= self.x + self.width + && other.y + other.height <= self.y + self.height + } + + /// A short one-line description used in lists and the status bar. + pub fn title(&self) -> String { + match &self.kind { + NodeKind::Text { text } => { + let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let line = line.trim_start_matches(['#', '>', '-', '*', ' ']).trim(); + if line.is_empty() { + "Empty note".to_owned() + } else { + truncate(line, 40) + } + } + NodeKind::File { file, .. } => file_name(file).to_owned(), + NodeKind::Link { url } => truncate(url.trim_start_matches("https://"), 40), + NodeKind::Group { label, .. } => label.clone().unwrap_or_else(|| "Group".to_owned()), + NodeKind::Unknown { type_name } => format!("<{type_name}>"), + } + } +} + +impl Serialize for Node { + fn serialize(&self, s: S) -> Result { + let mut map = Map::new(); + map.insert("id".into(), Value::String(self.id.clone())); + map.insert( + "type".into(), + Value::String(self.kind.type_name().to_owned()), + ); + match &self.kind { + NodeKind::Text { text } => { + map.insert("text".into(), Value::String(text.clone())); + } + NodeKind::File { file, subpath } => { + map.insert("file".into(), Value::String(file.clone())); + insert_opt_str(&mut map, "subpath", subpath.as_deref()); + } + NodeKind::Link { url } => { + map.insert("url".into(), Value::String(url.clone())); + } + NodeKind::Group { + label, + background, + background_style, + } => { + insert_opt_str(&mut map, "label", label.as_deref()); + insert_opt_str(&mut map, "background", background.as_deref()); + insert_opt_str( + &mut map, + "backgroundStyle", + background_style.map(BackgroundStyle::label), + ); + } + NodeKind::Unknown { .. } => {} + } + map.insert("x".into(), Value::from(self.x)); + map.insert("y".into(), Value::from(self.y)); + map.insert("width".into(), Value::from(self.width)); + map.insert("height".into(), Value::from(self.height)); + if let Some(color) = &self.color { + map.insert("color".into(), Value::String(color.as_string())); + } + for (k, v) in &self.extra { + map.entry(k.clone()).or_insert_with(|| v.clone()); + } + map.serialize(s) + } +} + +impl<'de> Deserialize<'de> for Node { + fn deserialize>(d: D) -> Result { + let mut map = Map::::deserialize(d)?; + let id = take_string(&mut map, "id") + .ok_or_else(|| D::Error::custom("node is missing a string \"id\""))?; + let type_name = take_string(&mut map, "type") + .ok_or_else(|| D::Error::custom(format!("node {id:?} is missing a \"type\"")))?; + + let kind = match type_name.as_str() { + "text" => NodeKind::Text { + text: take_string(&mut map, "text").unwrap_or_default(), + }, + "file" => NodeKind::File { + file: take_string(&mut map, "file").unwrap_or_default(), + subpath: take_string(&mut map, "subpath"), + }, + "link" => NodeKind::Link { + url: take_string(&mut map, "url").unwrap_or_default(), + }, + "group" => NodeKind::Group { + label: take_string(&mut map, "label"), + background: take_string(&mut map, "background"), + background_style: take_enum(&mut map, "backgroundStyle"), + }, + _ => NodeKind::Unknown { type_name }, + }; + + let color = take_color(&mut map); + Ok(Node { + id, + kind, + x: take_i32(&mut map, "x").unwrap_or(0), + y: take_i32(&mut map, "y").unwrap_or(0), + width: take_i32(&mut map, "width").unwrap_or(250).max(1), + height: take_i32(&mut map, "height").unwrap_or(60).max(1), + color, + extra: map, + }) + } +} + +// --------------------------------------------------------------------------- +// Edges +// --------------------------------------------------------------------------- + +/// A connection between two nodes. +#[derive(Debug, Clone, PartialEq)] +pub struct Edge { + pub id: String, + pub from_node: String, + pub from_side: Option, + pub from_end: Option, + pub to_node: String, + pub to_side: Option, + pub to_end: Option, + pub color: Option, + pub label: Option, + pub extra: Extra, +} + +impl Edge { + pub fn new( + id: impl Into, + from_node: impl Into, + from_side: Side, + to_node: impl Into, + to_side: Side, + ) -> Self { + Self { + id: id.into(), + from_node: from_node.into(), + from_side: Some(from_side), + from_end: None, + to_node: to_node.into(), + to_side: Some(to_side), + to_end: None, + color: None, + label: None, + extra: Extra::new(), + } + } + + /// Shape drawn where the edge leaves its source; the spec's default for + /// `fromEnd` is `none`. + pub fn start_shape(&self) -> EndShape { + self.from_end.unwrap_or(EndShape::None) + } + + /// Shape drawn where the edge reaches its target; the spec's default for + /// `toEnd` is `arrow`. + pub fn end_shape(&self) -> EndShape { + self.to_end.unwrap_or(EndShape::Arrow) + } +} + +impl Serialize for Edge { + fn serialize(&self, s: S) -> Result { + let mut map = Map::new(); + map.insert("id".into(), Value::String(self.id.clone())); + map.insert("fromNode".into(), Value::String(self.from_node.clone())); + insert_opt_str(&mut map, "fromSide", self.from_side.map(Side::label)); + insert_opt_str(&mut map, "fromEnd", self.from_end.map(end_label)); + map.insert("toNode".into(), Value::String(self.to_node.clone())); + insert_opt_str(&mut map, "toSide", self.to_side.map(Side::label)); + insert_opt_str(&mut map, "toEnd", self.to_end.map(end_label)); + if let Some(color) = &self.color { + map.insert("color".into(), Value::String(color.as_string())); + } + insert_opt_str(&mut map, "label", self.label.as_deref()); + for (k, v) in &self.extra { + map.entry(k.clone()).or_insert_with(|| v.clone()); + } + map.serialize(s) + } +} + +impl<'de> Deserialize<'de> for Edge { + fn deserialize>(d: D) -> Result { + let mut map = Map::::deserialize(d)?; + let id = take_string(&mut map, "id") + .ok_or_else(|| D::Error::custom("edge is missing a string \"id\""))?; + let from_node = take_string(&mut map, "fromNode") + .ok_or_else(|| D::Error::custom(format!("edge {id:?} is missing \"fromNode\"")))?; + let to_node = take_string(&mut map, "toNode") + .ok_or_else(|| D::Error::custom(format!("edge {id:?} is missing \"toNode\"")))?; + let color = take_color(&mut map); + Ok(Edge { + id, + from_node, + from_side: take_enum(&mut map, "fromSide"), + from_end: take_enum(&mut map, "fromEnd"), + to_node, + to_side: take_enum(&mut map, "toSide"), + to_end: take_enum(&mut map, "toEnd"), + color, + label: take_string(&mut map, "label"), + extra: map, + }) + } +} + +fn end_label(end: EndShape) -> &'static str { + match end { + EndShape::None => "none", + EndShape::Arrow => "arrow", + } +} + +// --------------------------------------------------------------------------- +// Canvas +// --------------------------------------------------------------------------- + +/// A whole `.canvas` document. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Canvas { + #[serde(default)] + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(flatten, default)] + pub extra: Extra, +} + +impl Canvas { + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Serialises to pretty-printed JSON, always including `nodes` and `edges`. + pub fn to_json(&self) -> String { + let mut out = serde_json::to_string_pretty(&CanvasOut { + nodes: &self.nodes, + edges: &self.edges, + extra: &self.extra, + }) + .expect("canvas serialization cannot fail"); + out.push('\n'); + out + } + + pub fn node(&self, id: &str) -> Option<&Node> { + self.nodes.iter().find(|n| n.id == id) + } + + pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> { + self.nodes.iter_mut().find(|n| n.id == id) + } + + pub fn edge(&self, id: &str) -> Option<&Edge> { + self.edges.iter().find(|e| e.id == id) + } + + pub fn edge_mut(&mut self, id: &str) -> Option<&mut Edge> { + self.edges.iter_mut().find(|e| e.id == id) + } + + /// Removes a node together with every edge attached to it. + pub fn remove_node(&mut self, id: &str) { + self.nodes.retain(|n| n.id != id); + self.edges.retain(|e| e.from_node != id && e.to_node != id); + } + + /// Ids of the nodes fully contained in the group `group_id`. + pub fn nodes_in_group(&self, group_id: &str) -> Vec { + let Some(group) = self.node(group_id) else { + return Vec::new(); + }; + self.nodes + .iter() + .filter(|n| n.id != group_id && group.contains(n)) + .map(|n| n.id.clone()) + .collect() + } + + /// An id that is not used by any node or edge in this canvas. + pub fn fresh_id(&self) -> String { + loop { + let id = random_id(); + let taken = + self.nodes.iter().any(|n| n.id == id) || self.edges.iter().any(|e| e.id == id); + if !taken { + return id; + } + } + } + + /// Drops edges that point at nodes which do not exist, returning how many + /// were removed. + pub fn drop_dangling_edges(&mut self) -> usize { + let ids: std::collections::HashSet<&str> = + self.nodes.iter().map(|n| n.id.as_str()).collect(); + let before = self.edges.len(); + self.edges + .retain(|e| ids.contains(e.from_node.as_str()) && ids.contains(e.to_node.as_str())); + before - self.edges.len() + } +} + +#[derive(Serialize)] +struct CanvasOut<'a> { + nodes: &'a Vec, + edges: &'a Vec, + #[serde(flatten)] + extra: &'a Extra, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// A 16 character lowercase hex id, matching the style used by Obsidian Canvas. +pub fn random_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = nanos ^ counter.wrapping_mul(0x9e37_79b9_7f4a_7c15); + // splitmix64 + let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + format!("{z:016x}") +} + +fn take_string(map: &mut Map, key: &str) -> Option { + match map.remove(key) { + Some(Value::String(s)) => Some(s), + // Not a string: put it back so it survives round-tripping. + Some(other) => { + map.insert(key.to_owned(), other); + None + } + None => None, + } +} + +fn take_i32(map: &mut Map, key: &str) -> Option { + match map.remove(key) { + Some(Value::Number(n)) => n + .as_f64() + .map(|f| f.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32), + Some(other) => { + map.insert(key.to_owned(), other); + None + } + None => None, + } +} + +fn take_enum Deserialize<'a>>(map: &mut Map, key: &str) -> Option { + let value = map.remove(key)?; + match T::deserialize(value.clone()) { + Ok(v) => Some(v), + Err(_) => { + map.insert(key.to_owned(), value); + None + } + } +} + +/// Reads `color`, keeping values the spec does not allow in `extra`. +fn take_color(map: &mut Map) -> Option { + let value = map.remove("color")?; + let raw = match &value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => { + map.insert("color".into(), value); + return None; + } + }; + match Color::parse(&raw) { + Some(color) => Some(color), + None => { + map.insert("color".into(), value); + None + } + } +} + +fn insert_opt_str(map: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value { + map.insert(key.to_owned(), Value::String(value.to_owned())); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_owned() + } else { + let head: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", head.trim_end()) + } +} + +/// The last component of a `/`- or `\`-separated path. +pub fn file_name(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r##"{ + "nodes": [ + {"id":"a","type":"text","text":"# Hello","x":-100,"y":-40,"width":250,"height":60,"color":"3"}, + {"id":"b","type":"file","file":"notes/todo.md","subpath":"#today","x":200,"y":0,"width":400,"height":400}, + {"id":"c","type":"group","label":"Ideas","x":-200,"y":-200,"width":800,"height":800,"color":"#ff00aa"}, + {"id":"d","type":"link","url":"https://jsoncanvas.org","x":0,"y":300,"width":400,"height":400} + ], + "edges": [ + {"id":"e1","fromNode":"a","fromSide":"right","toNode":"b","toSide":"left","label":"see also","color":"5"} + ] + }"##; + + #[test] + fn parses_every_node_type() { + let canvas = Canvas::from_json(SAMPLE).unwrap(); + assert_eq!(canvas.nodes.len(), 4); + assert_eq!(canvas.edges.len(), 1); + assert!(matches!(canvas.nodes[0].kind, NodeKind::Text { .. })); + assert_eq!(canvas.nodes[0].color, Some(Color::Preset(3))); + assert_eq!(canvas.nodes[2].color, Some(Color::Hex("#ff00aa".into()))); + let NodeKind::File { subpath, .. } = &canvas.nodes[1].kind else { + panic!("expected a file node"); + }; + assert_eq!(subpath.as_deref(), Some("#today")); + } + + #[test] + fn round_trips_without_losing_data() { + let canvas = Canvas::from_json(SAMPLE).unwrap(); + let again = Canvas::from_json(&canvas.to_json()).unwrap(); + assert_eq!(canvas, again); + } + + /// Guards the `preserve_order` feature of `serde_json`: without it the + /// attributes come out alphabetically and every save reshuffles the file. + #[test] + fn attributes_are_written_in_spec_order() { + let json = Canvas::from_json(SAMPLE).unwrap().to_json(); + let first_node = json.split("\"edges\"").next().unwrap(); + let at = |key: &str| first_node.find(key).unwrap_or(usize::MAX); + assert!( + at("\"id\"") < at("\"type\"") + && at("\"type\"") < at("\"text\"") + && at("\"text\"") < at("\"x\"") + && at("\"x\"") < at("\"width\"") + && at("\"width\"") < at("\"color\""), + "{json}" + ); + } + + #[test] + fn keeps_unknown_attributes_and_node_types() { + let json = r#"{"nodes":[{"id":"x","type":"quantum","x":0,"y":0,"width":10,"height":10, + "spin":"up"}],"edges":[],"appVersion":"1.2.3"}"#; + let canvas = Canvas::from_json(json).unwrap(); + assert!(matches!(canvas.nodes[0].kind, NodeKind::Unknown { .. })); + let out = canvas.to_json(); + assert!(out.contains("\"quantum\""), "{out}"); + assert!(out.contains("\"spin\""), "{out}"); + assert!(out.contains("\"appVersion\""), "{out}"); + } + + #[test] + fn edge_end_defaults_follow_the_spec() { + let edge = Edge::new("e", "a", Side::Right, "b", Side::Left); + assert_eq!(edge.start_shape(), EndShape::None); + assert_eq!(edge.end_shape(), EndShape::Arrow); + // Unset ends are not written back out. + let json = serde_json::to_string(&edge).unwrap(); + assert!(!json.contains("fromEnd"), "{json}"); + } + + #[test] + fn rejects_invalid_colors_but_keeps_the_value() { + assert_eq!(Color::parse("7"), None); + assert_eq!(Color::parse("#12"), None); + assert_eq!(Color::parse("#AABBCC"), Some(Color::Hex("#aabbcc".into()))); + let canvas = + Canvas::from_json(r#"{"nodes":[{"id":"a","type":"text","text":"","color":"nope"}]}"#) + .unwrap(); + assert_eq!(canvas.nodes[0].color, None); + assert!(canvas.to_json().contains("nope")); + } + + #[test] + fn removing_a_node_removes_its_edges() { + let mut canvas = Canvas::from_json(SAMPLE).unwrap(); + canvas.remove_node("a"); + assert!(canvas.edges.is_empty()); + } + + #[test] + fn groups_report_the_nodes_they_contain() { + let canvas = Canvas::from_json(SAMPLE).unwrap(); + let mut inside = canvas.nodes_in_group("c"); + inside.sort(); + // "d" hangs out of the bottom of the group, so it is not a member. + assert_eq!(inside, vec!["a".to_owned(), "b".to_owned()]); + } + + #[test] + fn ids_are_unique() { + let ids: std::collections::HashSet = (0..1000).map(|_| random_id()).collect(); + assert_eq!(ids.len(), 1000); + } +} diff --git a/src/nodes.rs b/src/nodes.rs new file mode 100644 index 0000000..3af1146 --- /dev/null +++ b/src/nodes.rs @@ -0,0 +1,352 @@ +//! Rendering the *body* of a node: markdown for text nodes, previews for file +//! nodes, the target of link nodes. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use eframe::egui::{self, Align, Layout, RichText, Ui}; + +use crate::markdown; +use crate::model::{Node, NodeKind, file_name}; +use crate::theme::Palette; + +/// Files larger than this are not previewed as text. +const MAX_PREVIEW_BYTES: usize = 64 * 1024; + +/// What a file node points at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileKind { + Image, + Text, + Other, +} + +pub fn file_kind(path: &str) -> FileKind { + let ext = Path::new(path) + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" => FileKind::Image, + "md" | "markdown" | "txt" | "text" | "canvas" | "json" | "toml" | "yaml" | "yml" + | "csv" | "log" | "rs" | "py" | "js" | "ts" | "html" | "css" | "sh" => FileKind::Text, + _ => FileKind::Other, + } +} + +/// Cache for the text previews of file nodes, so the disk is not hit per frame. +#[derive(Default)] +pub struct PreviewCache { + text: HashMap>, +} + +impl PreviewCache { + pub fn clear(&mut self) { + self.text.clear(); + } + + fn text_preview(&mut self, path: &Path) -> &Result { + self.text.entry(path.to_path_buf()).or_insert_with(|| { + match std::fs::read(path) { + Ok(bytes) => { + let truncated = bytes.len() > MAX_PREVIEW_BYTES; + let head = &bytes[..bytes.len().min(MAX_PREVIEW_BYTES)]; + let mut text = String::from_utf8_lossy(head).into_owned(); + if truncated { + text.push_str("\n\n…"); + } + Ok(text) + } + Err(err) => Err(format!("{err}")), + } + }) + } +} + +/// Resolves a node path against the folder the canvas lives in. +pub fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf { + let path = Path::new(raw); + match base_dir { + Some(base) if path.is_relative() => base.join(path), + _ => path.to_path_buf(), + } +} + +/// A `file://` URI for egui's image loader. +/// +/// On Windows the loader wants the `file:///C:/dir/file.png` form: without the +/// third slash it reads the drive letter as a UNC host name. It does not +/// percent-decode, so nothing here is escaped. +pub fn file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + format!("file:///{}", text.trim_start_matches('/')) + } else { + format!("file://{text}") + } +} + +/// Everything the body renderer needs from the outside world. +pub struct BodyContext<'a> { + pub palette: &'a Palette, + pub base_dir: Option<&'a Path>, + pub cache: &'a mut PreviewCache, + /// Current canvas scale, used to skip detail when zoomed far out. + pub scale: f32, +} + +/// Draws the contents of `node` inside `ui`, returning a link/file the user +/// activated by clicking. +pub fn render_body(ui: &mut Ui, node: &Node, ctx: &mut BodyContext<'_>) -> Option { + match &node.kind { + NodeKind::Text { text } => render_text(ui, text, ctx), + NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), ctx), + NodeKind::Link { url } => render_link(ui, url, ctx), + NodeKind::Group { .. } => None, + NodeKind::Unknown { type_name } => { + ui.label( + RichText::new(format!("Unsupported node type “{type_name}”")) + .size(13.0) + .color(ctx.palette.text_muted) + .italics(), + ); + ui.label( + RichText::new("Its data is preserved when the file is saved.") + .size(11.0) + .color(ctx.palette.text_muted), + ); + None + } + } +} + +fn render_text(ui: &mut Ui, text: &str, ctx: &mut BodyContext<'_>) -> Option { + if text.trim().is_empty() { + ui.label( + RichText::new("Empty note") + .size(13.0) + .italics() + .color(ctx.palette.text_muted), + ); + return None; + } + let blocks = markdown::parse(text); + markdown::render(ui, &blocks, ctx.palette, 14.0) +} + +fn render_file( + ui: &mut Ui, + file: &str, + subpath: Option<&str>, + ctx: &mut BodyContext<'_>, +) -> Option { + let palette = ctx.palette; + let path = resolve_path(ctx.base_dir, file); + let kind = file_kind(file); + let mut activated = None; + + // Header: the file name, plus the subpath when the node points inside a file. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let icon = match kind { + FileKind::Image => "IMG", + FileKind::Text => "DOC", + FileKind::Other => "FILE", + }; + ui.label(RichText::new(icon).size(10.0).color(palette.text_muted)); + if ui + .link( + RichText::new(file_name(file)) + .size(13.0) + .strong() + .color(palette.text), + ) + .on_hover_text(path.display().to_string()) + .clicked() + { + activated = Some(file.to_owned()); + } + if let Some(subpath) = subpath { + ui.label( + RichText::new(subpath) + .size(12.0) + .color(palette.text_muted), + ); + } + }); + + if ctx.scale < 0.4 { + return activated; + } + + if !path.exists() { + ui.add_space(4.0); + ui.label( + RichText::new("File not found") + .size(12.0) + .italics() + .color(palette.danger), + ); + return activated; + } + + ui.add_space(4.0); + match kind { + FileKind::Image => { + ui.add( + egui::Image::new(file_uri(&path)) + .maintain_aspect_ratio(true) + .fit_to_exact_size(ui.available_size()) + .corner_radius(4.0), + ); + } + FileKind::Text => match ctx.cache.text_preview(&path) { + Ok(text) => { + let excerpt = excerpt_for(text, subpath); + let blocks = markdown::parse(&excerpt); + if let Some(link) = markdown::render(ui, &blocks, palette, 13.0) { + activated = Some(link); + } + } + Err(err) => { + ui.label( + RichText::new(format!("Could not read the file: {err}")) + .size(12.0) + .color(palette.danger), + ); + } + }, + FileKind::Other => { + ui.label( + RichText::new("No preview available") + .size(12.0) + .italics() + .color(palette.text_muted), + ); + } + } + activated +} + +/// The part of a text file a file node shows: the whole file, or the section a +/// `subpath` (`#heading`) points at. +fn excerpt_for(text: &str, subpath: Option<&str>) -> String { + let Some(heading) = subpath.and_then(|s| s.strip_prefix('#')) else { + return text.to_owned(); + }; + let heading = heading.trim().to_ascii_lowercase(); + let mut out = Vec::new(); + let mut level = 0usize; + for line in text.lines() { + let hashes = line.chars().take_while(|c| *c == '#').count(); + let is_heading = (1..=6).contains(&hashes) && line[hashes..].starts_with(' '); + if level == 0 { + if is_heading && line[hashes..].trim().to_ascii_lowercase() == heading { + level = hashes; + out.push(line.to_owned()); + } + } else { + if is_heading && hashes <= level { + break; + } + out.push(line.to_owned()); + } + } + if out.is_empty() { + format!("*Section “{heading}” not found*") + } else { + out.join("\n") + } +} + +fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option { + let palette = ctx.palette; + let mut activated = None; + ui.with_layout(Layout::top_down(Align::Min), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.label(RichText::new("URL").size(10.0).color(palette.text_muted)); + ui.label( + RichText::new(host_of(url)) + .size(13.0) + .strong() + .color(palette.text), + ); + }); + ui.add_space(2.0); + if ui + .link(RichText::new(url).size(12.0).color(palette.accent)) + .on_hover_text("Open in the default browser") + .clicked() + { + activated = Some(url.to_owned()); + } + }); + activated +} + +/// The host part of a URL, for the link node header. +fn host_of(url: &str) -> &str { + let rest = url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(url); + let host = rest.split(['/', '?', '#']).next().unwrap_or(rest); + if host.is_empty() { url } else { host } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_file_kinds() { + assert_eq!(file_kind("a/b/photo.PNG"), FileKind::Image); + assert_eq!(file_kind("notes.md"), FileKind::Text); + assert_eq!(file_kind("archive.zip"), FileKind::Other); + assert_eq!(file_kind("no-extension"), FileKind::Other); + } + + #[test] + fn resolves_relative_paths_against_the_canvas_folder() { + let base = Path::new("/vault/boards"); + assert_eq!( + resolve_path(Some(base), "notes/a.md"), + PathBuf::from("/vault/boards/notes/a.md") + ); + // Absolute paths are left alone. + let absolute = if cfg!(windows) { "C:\\tmp\\a.md" } else { "/tmp/a.md" }; + assert_eq!( + resolve_path(Some(base), absolute), + PathBuf::from(absolute) + ); + } + + #[test] + fn a_subpath_selects_one_section() { + let text = "# One\nalpha\n\n## Two\nbeta\n\n# Three\ngamma"; + assert_eq!(excerpt_for(text, Some("#Two")), "## Two\nbeta\n"); + assert!(excerpt_for(text, Some("#Missing")).contains("not found")); + assert_eq!(excerpt_for(text, None), text); + } + + #[test] + fn image_uris_use_the_form_the_loader_expects() { + let uri = file_uri(Path::new(if cfg!(windows) { + "C:\\vault\\a b.png" + } else { + "/vault/a b.png" + })); + if cfg!(windows) { + assert_eq!(uri, "file:///C:/vault/a b.png"); + } else { + assert_eq!(uri, "file:///vault/a b.png"); + } + } + + #[test] + fn shows_the_host_of_a_link() { + assert_eq!(host_of("https://jsoncanvas.org/spec/1.0/"), "jsoncanvas.org"); + assert_eq!(host_of("example.com"), "example.com"); + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..343884e --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,178 @@ +//! Colours: the six `canvasColor` presets plus the light and dark palettes the +//! editor paints with. + +use eframe::egui::Color32; + +use crate::model::Color; + +/// The six preset colours, in spec order (`"1"` … `"6"`). +pub const PRESETS: [Color32; 6] = [ + Color32::from_rgb(0xfb, 0x46, 0x4c), // 1 red + Color32::from_rgb(0xe9, 0x97, 0x3f), // 2 orange + Color32::from_rgb(0xe0, 0xde, 0x71), // 3 yellow + Color32::from_rgb(0x44, 0xcf, 0x6e), // 4 green + Color32::from_rgb(0x53, 0xdf, 0xdd), // 5 cyan + Color32::from_rgb(0xa8, 0x82, 0xff), // 6 purple +]; + +/// Every colour the canvas is painted with, for one of the two themes. +#[derive(Clone, Copy)] +pub struct Palette { + pub dark: bool, + pub canvas_bg: Color32, + pub grid: Color32, + pub grid_strong: Color32, + pub surface: Color32, + pub surface_muted: Color32, + pub border: Color32, + pub text: Color32, + pub text_muted: Color32, + pub accent: Color32, + pub edge: Color32, + pub danger: Color32, +} + +impl Palette { + pub fn dark() -> Self { + Self { + dark: true, + canvas_bg: Color32::from_rgb(0x1a, 0x1a, 0x1e), + grid: Color32::from_rgb(0x26, 0x26, 0x2c), + grid_strong: Color32::from_rgb(0x30, 0x30, 0x38), + surface: Color32::from_rgb(0x24, 0x24, 0x2a), + surface_muted: Color32::from_rgb(0x2c, 0x2c, 0x33), + border: Color32::from_rgb(0x44, 0x44, 0x4e), + text: Color32::from_rgb(0xe4, 0xe4, 0xe8), + text_muted: Color32::from_rgb(0x95, 0x95, 0xa0), + accent: Color32::from_rgb(0x70, 0x9c, 0xff), + edge: Color32::from_rgb(0x8a, 0x8a, 0x98), + danger: Color32::from_rgb(0xe5, 0x53, 0x53), + } + } + + pub fn light() -> Self { + Self { + dark: false, + canvas_bg: Color32::from_rgb(0xf7, 0xf7, 0xf9), + grid: Color32::from_rgb(0xe6, 0xe6, 0xea), + grid_strong: Color32::from_rgb(0xd6, 0xd6, 0xdc), + surface: Color32::from_rgb(0xff, 0xff, 0xff), + surface_muted: Color32::from_rgb(0xf0, 0xf0, 0xf4), + border: Color32::from_rgb(0xcd, 0xcd, 0xd6), + text: Color32::from_rgb(0x1e, 0x1e, 0x24), + text_muted: Color32::from_rgb(0x6b, 0x6b, 0x78), + accent: Color32::from_rgb(0x2f, 0x6f, 0xed), + edge: Color32::from_rgb(0x76, 0x76, 0x84), + danger: Color32::from_rgb(0xc4, 0x2b, 0x2b), + } + } + + /// Turns a `canvasColor` into a paintable colour. + pub fn resolve(&self, color: &Color) -> Color32 { + match color { + Color::Preset(n) => PRESETS[(*n as usize - 1).min(5)], + Color::Hex(hex) => parse_hex(hex).unwrap_or(self.accent), + } + } + + /// Border and fill for a node with the given `canvasColor`. + pub fn node_colors(&self, color: Option<&Color>) -> (Color32, Color32) { + match color { + None => (self.surface, self.border), + Some(color) => { + let accent = self.resolve(color); + let strength = if self.dark { 0.16 } else { 0.10 }; + (blend(accent, self.surface, strength), accent) + } + } + } + + /// Fill and border for a group box. + pub fn group_colors(&self, color: Option<&Color>) -> (Color32, Color32) { + let border = color.map_or(self.border, |c| self.resolve(c)); + let strength = if self.dark { 0.10 } else { 0.07 }; + (blend(border, self.canvas_bg, strength), border) + } + + /// Colour of an edge, honouring its optional `canvasColor`. + pub fn edge_color(&self, color: Option<&Color>) -> Color32 { + color.map_or(self.edge, |c| self.resolve(c)) + } +} + +/// Parses `#rgb` / `#rrggbb`. +pub fn parse_hex(hex: &str) -> Option { + let hex = hex.trim().strip_prefix('#')?; + let expand = |c: u8| c * 17; + match hex.len() { + 3 => { + let mut it = hex.chars().map(|c| c.to_digit(16).map(|d| expand(d as u8))); + Some(Color32::from_rgb(it.next()??, it.next()??, it.next()??)) + } + 6 => { + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?)) + } + _ => None, + } +} + +/// Mixes `fg` into `bg` with the given strength in `0..=1`. +pub fn blend(fg: Color32, bg: Color32, strength: f32) -> Color32 { + let mix = |a: u8, b: u8| (a as f32 * strength + b as f32 * (1.0 - strength)).round() as u8; + Color32::from_rgb( + mix(fg.r(), bg.r()), + mix(fg.g(), bg.g()), + mix(fg.b(), bg.b()), + ) +} + +/// Black or white, whichever stays readable on `background`. +pub fn contrasting_text(background: Color32) -> Color32 { + let luma = 0.299 * background.r() as f32 + 0.587 * background.g() as f32 + + 0.114 * background.b() as f32; + if luma > 150.0 { + Color32::from_rgb(0x1e, 0x1e, 0x24) + } else { + Color32::from_rgb(0xf2, 0xf2, 0xf5) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_both_hex_lengths() { + assert_eq!(parse_hex("#fff"), Some(Color32::WHITE)); + assert_eq!(parse_hex("#ff0000"), Some(Color32::from_rgb(255, 0, 0))); + assert_eq!(parse_hex("nope"), None); + assert_eq!(parse_hex("#12345"), None); + } + + #[test] + fn presets_resolve_by_number() { + let palette = Palette::dark(); + assert_eq!(palette.resolve(&Color::Preset(1)), PRESETS[0]); + assert_eq!(palette.resolve(&Color::Preset(6)), PRESETS[5]); + assert_eq!( + palette.resolve(&Color::Hex("#ff00ff".into())), + Color32::from_rgb(255, 0, 255) + ); + } + + #[test] + fn a_coloured_node_keeps_a_readable_fill() { + let palette = Palette::dark(); + let (fill, border) = palette.node_colors(Some(&Color::Preset(1))); + assert_eq!(border, PRESETS[0]); + // The fill is only tinted, it stays close to the surface colour. + assert!(fill.r() > palette.surface.r() && fill.r() < PRESETS[0].r()); + } + + #[test] + fn text_contrast_flips_with_the_background() { + assert_eq!(contrasting_text(Color32::WHITE).r(), 0x1e); + assert_eq!(contrasting_text(Color32::BLACK).r(), 0xf2); + } +} diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..980ef01 --- /dev/null +++ b/src/view.rs @@ -0,0 +1,1996 @@ +//! The infinite canvas: pan/zoom, painting nodes and edges, and every direct +//! manipulation gesture (select, move, resize, connect, edit). + +use std::collections::HashMap; + +use eframe::egui::{ + self, Align, Color32, CornerRadius, CursorIcon, LayerId, Layout, PointerButton, Pos2, Rect, + RichText, Sense, Shape, Stroke, StrokeKind, TextEdit, UiBuilder, Vec2, epaint, pos2, vec2, +}; +use eframe::emath::TSTransform; + +use crate::document::Document; +use crate::geometry::{ + anchor, auto_sides, bezier_point, bezier_tangent, bounds_of, curve_between, distance_to_curve, + edge_curve, node_rect, +}; +use crate::model::{Canvas, Edge, EndShape, Node, NodeKind, Side, MIN_NODE_SIZE}; +use crate::nodes::{self, BodyContext, PreviewCache}; +use crate::theme::{self, Palette}; + +/// Zoom limits. Beyond 3x text starts to look soft, which is the usual +/// trade-off of transformed layers. +const MIN_ZOOM: f32 = 0.05; +const MAX_ZOOM: f32 = 3.0; +/// Spacing of the background grid, in canvas units. +const GRID: f32 = 20.0; +/// How close the pointer has to be to an edge to hit it, in screen pixels. +const EDGE_HIT_RADIUS: f32 = 8.0; +/// Size of the resize/connect handles, in screen pixels. +const HANDLE_SIZE: f32 = 9.0; +const NODE_PADDING: f32 = 10.0; +const NODE_CORNER: f32 = 6.0; + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +/// The current selection. Small, so plain `Vec`s keep the order stable. +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct Selection { + pub nodes: Vec, + pub edges: Vec, +} + +impl Selection { + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() && self.edges.is_empty() + } + + pub fn len(&self) -> usize { + self.nodes.len() + self.edges.len() + } + + pub fn clear(&mut self) { + self.nodes.clear(); + self.edges.clear(); + } + + pub fn has_node(&self, id: &str) -> bool { + self.nodes.iter().any(|n| n == id) + } + + pub fn has_edge(&self, id: &str) -> bool { + self.edges.iter().any(|e| e == id) + } + + pub fn only_node(&self) -> Option<&str> { + match (self.nodes.len(), self.edges.len()) { + (1, 0) => Some(self.nodes[0].as_str()), + _ => None, + } + } + + pub fn only_edge(&self) -> Option<&str> { + match (self.nodes.len(), self.edges.len()) { + (0, 1) => Some(self.edges[0].as_str()), + _ => None, + } + } + + fn set_node(&mut self, id: &str) { + self.clear(); + self.nodes.push(id.to_owned()); + } + + fn toggle_node(&mut self, id: &str) { + if let Some(at) = self.nodes.iter().position(|n| n == id) { + self.nodes.remove(at); + } else { + self.nodes.push(id.to_owned()); + } + } + + fn set_edge(&mut self, id: &str) { + self.clear(); + self.edges.push(id.to_owned()); + } + + fn toggle_edge(&mut self, id: &str) { + if let Some(at) = self.edges.iter().position(|e| e == id) { + self.edges.remove(at); + } else { + self.edges.push(id.to_owned()); + } + } + + /// Drops ids that are no longer in the canvas (after undo, for instance). + fn prune(&mut self, canvas: &Canvas) { + self.nodes.retain(|id| canvas.node(id).is_some()); + self.edges.retain(|id| canvas.edge(id).is_some()); + } +} + +// --------------------------------------------------------------------------- +// Gestures +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Handle { + NorthWest, + North, + NorthEast, + East, + SouthEast, + South, + SouthWest, + West, +} + +impl Handle { + const ALL: [Self; 8] = [ + Self::NorthWest, + Self::North, + Self::NorthEast, + Self::East, + Self::SouthEast, + Self::South, + Self::SouthWest, + Self::West, + ]; + + /// Position of the handle on a node's box. + fn position(self, rect: Rect) -> Pos2 { + let (cx, cy) = (rect.center().x, rect.center().y); + match self { + Self::NorthWest => rect.left_top(), + Self::North => pos2(cx, rect.top()), + Self::NorthEast => rect.right_top(), + Self::East => pos2(rect.right(), cy), + Self::SouthEast => rect.right_bottom(), + Self::South => pos2(cx, rect.bottom()), + Self::SouthWest => rect.left_bottom(), + Self::West => pos2(rect.left(), cy), + } + } + + fn cursor(self) -> CursorIcon { + match self { + Self::NorthWest | Self::SouthEast => CursorIcon::ResizeNwSe, + Self::NorthEast | Self::SouthWest => CursorIcon::ResizeNeSw, + Self::North | Self::South => CursorIcon::ResizeVertical, + Self::East | Self::West => CursorIcon::ResizeHorizontal, + } + } + + /// Applies a drag of `delta` to `rect`, keeping the opposite side fixed. + fn resize(self, rect: Rect, delta: Vec2) -> Rect { + let mut r = rect; + match self { + Self::NorthWest => { + r.min += delta; + } + Self::North => r.min.y += delta.y, + Self::NorthEast => { + r.max.x += delta.x; + r.min.y += delta.y; + } + Self::East => r.max.x += delta.x, + Self::SouthEast => { + r.max += delta; + } + Self::South => r.max.y += delta.y, + Self::SouthWest => { + r.min.x += delta.x; + r.max.y += delta.y; + } + Self::West => r.min.x += delta.x, + } + r + } +} + +/// The gesture currently in progress. +#[derive(Debug, Clone, PartialEq)] +enum Gesture { + None, + /// Moving nodes; `origin` holds where each node started. + Move { + origin: HashMap, + accumulated: Vec2, + }, + Resize { + id: String, + handle: Handle, + origin: Rect, + accumulated: Vec2, + }, + /// Rubber band selection from `start` (canvas coordinates). + Band { start: Pos2, add: bool }, + /// Dragging a new edge out of `from`/`side`. + Connect { from: String, side: Side }, +} + +/// What the view wants the application to do after a frame. +#[derive(Default)] +pub struct ViewOutput { + /// A URL or file path the user activated. + pub open_target: Option, + /// A short message for the status bar. + pub status: Option, +} + +// --------------------------------------------------------------------------- +// The view +// --------------------------------------------------------------------------- + +pub struct CanvasView { + /// Canvas space to screen space. + to_global: TSTransform, + pub selection: Selection, + gesture: Gesture, + /// Node whose text is being edited in place. + editing: Option, + /// Set for one frame to move keyboard focus into a freshly opened editor. + focus_editor: bool, + hovered_edge: Option, + hovered_node: Option, + /// Node whose connect handles are on screen, held still during a drag. + connect_target: Option, + pub show_grid: bool, + pub snap_to_grid: bool, + pub previews: PreviewCache, + /// Screen rectangle the canvas was drawn in last frame, `None` until the + /// canvas has been laid out once. + viewport: Option, + /// Framing asked for before the viewport size was known. + pending_fit: Option, +} + +/// A framing request that has to wait for the first frame: until the canvas +/// has been laid out, its size and position on screen are still a guess. +#[derive(Debug, Clone, Copy, PartialEq)] +enum PendingFit { + /// Put the canvas origin in the middle of the viewport. + Origin, + /// Frame this canvas rectangle. + Rect(Rect), +} + +impl Default for CanvasView { + fn default() -> Self { + Self::new() + } +} + +impl CanvasView { + pub fn new() -> Self { + Self { + to_global: TSTransform::IDENTITY, + selection: Selection::default(), + gesture: Gesture::None, + editing: None, + focus_editor: false, + hovered_edge: None, + hovered_node: None, + connect_target: None, + show_grid: true, + snap_to_grid: false, + previews: PreviewCache::default(), + viewport: None, + pending_fit: None, + } + } + + pub fn zoom(&self) -> f32 { + self.to_global.scaling + } + + pub fn stop_editing(&mut self) { + self.editing = None; + } + + /// Starts in-place editing of a text node or group label. + pub fn edit_node(&mut self, id: &str) { + self.editing = Some(id.to_owned()); + self.focus_editor = true; + } + + pub fn centre_in_canvas(&self) -> Pos2 { + self.to_global.inverse() * self.viewport().center() + } + + /// The viewport, falling back to a plausible size before the first frame. + fn viewport(&self) -> Rect { + self.viewport + .unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))) + } + + /// Sets the zoom level, keeping the middle of the viewport in place. + pub fn set_zoom(&mut self, zoom: f32) { + let zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM); + let centre = self.viewport().center().to_vec2(); + let factor = zoom / self.to_global.scaling; + self.to_global.translation = centre + (self.to_global.translation - centre) * factor; + self.to_global.scaling = zoom; + } + + /// Frames `rect` (canvas coordinates) in the viewport. + pub fn look_at(&mut self, rect: Rect) { + // Framing against a guessed viewport would leave the content off in a + // corner, so the request waits for the layout instead. + let Some(viewport) = self.viewport else { + self.pending_fit = Some(PendingFit::Rect(rect)); + return; + }; + let rect = if rect.width() < 1.0 || rect.height() < 1.0 { + Rect::from_center_size(rect.center(), vec2(400.0, 300.0)) + } else { + rect + }; + let margin = 60.0; + let available = Vec2::new( + (viewport.width() - margin).max(50.0), + (viewport.height() - margin).max(50.0), + ); + // Never zoom past 1:1 when framing something, that only looks blurry. + let scale = (available / rect.size()).min_elem().clamp(MIN_ZOOM, 1.0); + self.to_global = TSTransform::from_translation( + viewport.center().to_vec2() - scale * rect.center().to_vec2(), + ) * TSTransform::from_scaling(scale); + } + + /// Frames the whole canvas, or resets the view when it is empty. + pub fn zoom_to_fit(&mut self, canvas: &Canvas) { + match bounds_of(canvas.nodes.iter().map(node_rect)) { + Some(bounds) => self.look_at(bounds.expand(40.0)), + None => self.centre_on_origin(), + } + } + + /// Puts the canvas origin in the middle of the viewport, at 1:1. + fn centre_on_origin(&mut self) { + match self.viewport { + Some(viewport) => { + self.to_global = TSTransform::from_translation(viewport.center().to_vec2()); + } + None => self.pending_fit = Some(PendingFit::Origin), + } + } + + pub fn zoom_to_selection(&mut self, canvas: &Canvas) { + let rects = self + .selection + .nodes + .iter() + .filter_map(|id| canvas.node(id)) + .map(node_rect); + if let Some(bounds) = bounds_of(rects) { + self.look_at(bounds.expand(80.0)); + } + } + + pub fn select_all(&mut self, canvas: &Canvas) { + self.selection.nodes = canvas.nodes.iter().map(|n| n.id.clone()).collect(); + self.selection.edges.clear(); + } + + pub fn select_node(&mut self, id: &str) { + self.selection.set_node(id); + } + + // -- editing commands ------------------------------------------------- + + /// Adds a node at a canvas position and selects it. + pub fn insert_node(&mut self, doc: &mut Document, kind: NodeKind, at: Pos2) -> String { + let (w, h) = default_size(&kind); + let id = doc.canvas.fresh_id(); + let label = format!("Add {} node", kind.type_name()); + doc.change(&label); + let node = Node::new( + id.clone(), + kind, + (at.x - w as f32 / 2.0).round() as i32, + (at.y - h as f32 / 2.0).round() as i32, + w, + h, + ); + // Groups belong behind everything else so they do not cover their members. + if node.is_group() { + doc.canvas.nodes.insert(0, node); + } else { + doc.canvas.nodes.push(node); + } + self.selection.set_node(&id); + id + } + + /// Deletes the selected nodes and edges. + pub fn delete_selection(&mut self, doc: &mut Document) -> Option { + if self.selection.is_empty() { + return None; + } + let nodes = self.selection.nodes.len(); + let edges_before = doc.canvas.edges.len(); + doc.change("Delete"); + for id in &self.selection.nodes { + doc.canvas.remove_node(id); + } + doc.canvas + .edges + .retain(|e| !self.selection.edges.contains(&e.id)); + let edges = edges_before - doc.canvas.edges.len(); + self.selection.clear(); + self.editing = None; + Some(format!( + "Deleted {}", + count_phrase(&[(nodes, "node"), (edges, "edge")]) + )) + } + + /// Copies the selection into `clipboard`. + pub fn copy_selection(&self, canvas: &Canvas) -> Option { + if self.selection.nodes.is_empty() { + return None; + } + let nodes: Vec = canvas + .nodes + .iter() + .filter(|n| self.selection.has_node(&n.id)) + .cloned() + .collect(); + // Keep the edges that run entirely inside the selection. + let edges: Vec = canvas + .edges + .iter() + .filter(|e| self.selection.has_node(&e.from_node) && self.selection.has_node(&e.to_node)) + .cloned() + .collect(); + Some(Clipboard { nodes, edges }) + } + + /// Pastes `clipboard`, offset so the copy is visible, and selects it. + pub fn paste(&mut self, doc: &mut Document, clipboard: &Clipboard, offset: Vec2) -> String { + doc.change("Paste"); + let mut ids: HashMap = HashMap::new(); + for node in &clipboard.nodes { + let mut copy = node.clone(); + // Pushing as we go keeps `fresh_id` from handing out the same id twice. + copy.id = doc.canvas.fresh_id(); + copy.x += offset.x.round() as i32; + copy.y += offset.y.round() as i32; + ids.insert(node.id.clone(), copy.id.clone()); + doc.canvas.nodes.push(copy); + } + let mut edges = Vec::new(); + for edge in &clipboard.edges { + let (Some(from), Some(to)) = (ids.get(&edge.from_node), ids.get(&edge.to_node)) else { + continue; + }; + let mut copy = edge.clone(); + copy.id = doc.canvas.fresh_id(); + copy.from_node = from.clone(); + copy.to_node = to.clone(); + edges.push(copy.clone()); + doc.canvas.edges.push(copy); + } + self.selection.clear(); + self.selection.nodes = ids.values().cloned().collect(); + format!( + "Pasted {}", + count_phrase(&[(ids.len(), "node"), (edges.len(), "edge")]) + ) + } + + /// Moves the selection by whole canvas units. + pub fn nudge(&mut self, doc: &mut Document, delta: Vec2) { + if self.selection.nodes.is_empty() { + return; + } + doc.begin_change("Move"); + let ids = self.with_group_members(&doc.canvas, &self.selection.nodes); + for id in ids { + if let Some(node) = doc.canvas.node_mut(&id) { + node.x += delta.x as i32; + node.y += delta.y as i32; + } + } + } + + /// Reorders the selected nodes within the file, which is the canvas z-order. + pub fn raise_selection(&mut self, doc: &mut Document, to_front: bool) { + if self.selection.nodes.is_empty() { + return; + } + doc.change(if to_front { "Bring to front" } else { "Send to back" }); + let mut moved: Vec = Vec::new(); + let mut rest: Vec = Vec::new(); + for node in doc.canvas.nodes.drain(..) { + if self.selection.has_node(&node.id) { + moved.push(node); + } else { + rest.push(node); + } + } + if to_front { + rest.extend(moved); + doc.canvas.nodes = rest; + } else { + moved.extend(rest); + doc.canvas.nodes = moved; + } + } + + /// Selected node ids plus the members of any selected group. + fn with_group_members(&self, canvas: &Canvas, ids: &[String]) -> Vec { + let mut all = ids.to_vec(); + for id in ids { + if canvas.node(id).is_some_and(Node::is_group) { + for member in canvas.nodes_in_group(id) { + if !all.contains(&member) { + all.push(member); + } + } + } + } + all + } + + // -- the frame -------------------------------------------------------- + + pub fn show( + &mut self, + ui: &mut egui::Ui, + doc: &mut Document, + palette: &Palette, + ) -> ViewOutput { + let mut output = ViewOutput::default(); + self.selection.prune(&doc.canvas); + if self + .editing + .as_ref() + .is_some_and(|id| doc.canvas.node(id).is_none()) + { + self.editing = None; + } + + let outer = ui.available_rect_before_wrap(); + self.viewport = Some(outer); + match self.pending_fit.take() { + Some(PendingFit::Origin) => self.centre_on_origin(), + Some(PendingFit::Rect(rect)) => self.look_at(rect), + None => {} + } + ui.allocate_rect(outer, Sense::hover()); + ui.painter().rect_filled(outer, 0.0, palette.canvas_bg); + + // A sub-layer carries the pan/zoom transform, so the widgets inside + // nodes keep working while the canvas is scaled. + let layer = LayerId::new(ui.layer_id().order, ui.id().with("canvas_layer")); + ui.ctx().set_sublayer(ui.layer_id(), layer); + let mut scene = ui.new_child( + UiBuilder::new() + .layer_id(layer) + .max_rect(Rect::from_min_size(Pos2::ZERO, Vec2::splat(1e5))) + .sense(Sense::hover()), + ); + + // The child inherits the parent's clip rectangle, which is in screen + // coordinates. Inside the layer that rectangle means a fixed patch of + // *canvas*, and egui clips interaction rectangles to it, so the clip + // has to be corrected before anything in the layer is interacted with: + // otherwise the background stops responding as soon as the view is + // panned away from where it started. + let visible = self.to_global.inverse() * outer; + let background_rect = visible.expand(visible.width().max(visible.height())); + scene.set_clip_rect(background_rect); + let background = scene.interact( + background_rect, + scene.id().with("background"), + Sense::click_and_drag(), + ); + self.handle_pan_and_zoom(&scene, &background); + + let visible = self.to_global.inverse() * outer; + scene.set_clip_rect(visible); + ui.ctx().set_transform_layer(layer, self.to_global); + + if self.show_grid { + self.paint_grid(&scene, visible, palette); + } + // Groups are painted first so they cannot cover the edges, which in + // turn run underneath the nodes they connect. + let mut interactions = + self.show_nodes(&mut scene, doc, palette, visible, &mut output, Pass::Groups); + self.paint_edges(&scene, &doc.canvas, visible, palette); + interactions.merge(self.show_nodes( + &mut scene, + doc, + palette, + visible, + &mut output, + Pass::Nodes, + )); + self.paint_selection(&scene, &doc.canvas, palette); + let handle_used = self.show_handles(&scene, doc, palette); + self.paint_gesture_overlay(&scene, &doc.canvas, palette); + + self.handle_interactions( + &scene, + doc, + &background, + interactions, + handle_used, + &mut output, + ); + output + } + + // -- input ------------------------------------------------------------ + + fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) { + if background.dragged_by(PointerButton::Middle) + || background.dragged_by(PointerButton::Secondary) + { + self.to_global.translation += self.to_global.scaling * background.drag_delta(); + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } + + if !background.contains_pointer() { + return; + } + let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else { + return; + }; + let (zoom_delta, scroll) = ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)); + if zoom_delta != 1.0 { + let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM) + / self.to_global.scaling; + let anchor = pointer.to_vec2(); + self.to_global.translation = anchor + (self.to_global.translation - anchor) * factor; + self.to_global.scaling *= factor; + } else if scroll != Vec2::ZERO { + self.to_global.translation += scroll; + } + } + + // -- painting --------------------------------------------------------- + + fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) { + // Below this the grid turns into noise, so it is skipped. + if self.to_global.scaling < 0.35 { + return; + } + let painter = ui.painter(); + let thin = Stroke::new(1.0 / self.to_global.scaling, palette.grid); + let thick = Stroke::new(1.0 / self.to_global.scaling, palette.grid_strong); + + let first_x = (visible.left() / GRID).floor() * GRID; + let mut x = first_x; + while x <= visible.right() { + let stroke = if (x / (GRID * 5.0)).fract().abs() < 1e-3 { thick } else { thin }; + painter.line_segment( + [pos2(x, visible.top()), pos2(x, visible.bottom())], + stroke, + ); + x += GRID; + } + let first_y = (visible.top() / GRID).floor() * GRID; + let mut y = first_y; + while y <= visible.bottom() { + let stroke = if (y / (GRID * 5.0)).fract().abs() < 1e-3 { thick } else { thin }; + painter.line_segment( + [pos2(visible.left(), y), pos2(visible.right(), y)], + stroke, + ); + y += GRID; + } + } + + fn paint_edges(&self, ui: &egui::Ui, canvas: &Canvas, visible: Rect, palette: &Palette) { + let painter = ui.painter(); + for edge in &canvas.edges { + let Some(curve) = curve_of(canvas, edge) else { + continue; + }; + let selected = self.selection.has_edge(&edge.id); + let hovered = self.hovered_edge.as_deref() == Some(edge.id.as_str()); + let color = if selected { + palette.accent + } else { + palette.edge_color(edge.color.as_ref()) + }; + let width = if selected || hovered { 3.0 } else { 2.0 }; + + if !crate::geometry::curve_bounds(curve).intersects(visible) { + continue; + } + + painter.add(Shape::CubicBezier(epaint::CubicBezierShape::from_points_stroke( + curve, + false, + Color32::TRANSPARENT, + Stroke::new(width, color), + ))); + + if edge.end_shape() == EndShape::Arrow { + paint_arrow(painter, curve[3], bezier_tangent(curve, 1.0), width, color); + } + if edge.start_shape() == EndShape::Arrow { + paint_arrow(painter, curve[0], -bezier_tangent(curve, 0.0), width, color); + } + if let Some(label) = edge.label.as_ref().filter(|l| !l.trim().is_empty()) { + paint_edge_label(painter, bezier_point(curve, 0.5), label, palette, color); + } + } + } + + /// Draws the visible nodes of one pass and returns what the pointer did to + /// them. + fn show_nodes( + &mut self, + scene: &mut egui::Ui, + doc: &mut Document, + palette: &Palette, + visible: Rect, + output: &mut ViewOutput, + pass: Pass, + ) -> NodeInteractions { + let mut interactions = NodeInteractions::default(); + let base_dir = doc.base_dir(); + let scale = self.to_global.scaling; + if pass == Pass::Groups { + self.hovered_node = None; + } + + for index in 0..doc.canvas.nodes.len() { + let (id, rect, is_group) = { + let node = &doc.canvas.nodes[index]; + (node.id.clone(), node_rect(node), node.is_group()) + }; + if is_group != (pass == Pass::Groups) || !rect.intersects(visible) { + continue; + } + + let response = scene.interact( + rect, + scene.id().with(("node", &id)), + Sense::click_and_drag(), + ); + let selected = self.selection.has_node(&id); + if response.contains_pointer() { + self.hovered_node = Some(id.clone()); + } + + self.paint_node_frame( + scene, + &doc.canvas.nodes[index], + rect, + palette, + selected, + response.contains_pointer(), + base_dir.as_deref(), + ); + + // Contents are skipped when they would be unreadable anyway. + if scale >= 0.18 && !is_group { + let inner = rect.shrink(NODE_PADDING); + if inner.width() > 4.0 && inner.height() > 4.0 { + if self.editing.as_deref() == Some(id.as_str()) { + self.show_text_editor(scene, doc, index, inner, palette); + } else { + let mut body = scene.new_child( + UiBuilder::new() + .max_rect(inner) + .layout(Layout::top_down(Align::Min)), + ); + body.set_clip_rect(inner.intersect(scene.clip_rect())); + let mut ctx = BodyContext { + palette, + base_dir: base_dir.as_deref(), + cache: &mut self.previews, + scale, + }; + if let Some(target) = + nodes::render_body(&mut body, &doc.canvas.nodes[index], &mut ctx) + { + output.open_target = Some(target); + } + } + } + } + if is_group { + self.show_group_label(scene, doc, index, rect, palette); + } + + if response.drag_started_by(PointerButton::Primary) { + interactions.drag_started = Some(id.clone()); + } + if response.dragged_by(PointerButton::Primary) { + interactions.dragging = true; + } + if response.drag_stopped() { + interactions.drag_stopped = true; + } + if response.clicked() { + interactions.clicked = Some(id.clone()); + } + if response.double_clicked() { + interactions.double_clicked = Some(id.clone()); + } + if response.contains_pointer() { + interactions.hovered = Some(id.clone()); + } + } + interactions + } + + #[allow(clippy::too_many_arguments)] + fn paint_node_frame( + &self, + ui: &egui::Ui, + node: &Node, + rect: Rect, + palette: &Palette, + selected: bool, + hovered: bool, + base_dir: Option<&std::path::Path>, + ) { + let painter = ui.painter(); + let corner = CornerRadius::same(NODE_CORNER as u8); + if node.is_group() { + let (fill, border) = palette.group_colors(node.color.as_ref()); + painter.rect_filled(rect, corner, fill); + if let NodeKind::Group { + background: Some(background), + background_style, + .. + } = &node.kind + { + paint_group_background(ui, rect, background, *background_style, base_dir); + } + painter.rect_stroke( + rect, + corner, + Stroke::new(if hovered { 2.5 } else { 1.5 }, border), + StrokeKind::Inside, + ); + } else { + let (fill, border) = palette.node_colors(node.color.as_ref()); + painter.rect_filled(rect.translate(vec2(0.0, 2.0)), corner, shadow(palette)); + painter.rect_filled(rect, corner, fill); + let width = if selected { + 2.5 + } else if hovered { + 2.0 + } else { + 1.5 + }; + let stroke_color = if selected { palette.accent } else { border }; + painter.rect_stroke(rect, corner, Stroke::new(width, stroke_color), StrokeKind::Inside); + } + } + + /// The label chip of a group, editable in place. + fn show_group_label( + &mut self, + scene: &mut egui::Ui, + doc: &mut Document, + index: usize, + rect: Rect, + palette: &Palette, + ) { + let id = doc.canvas.nodes[index].id.clone(); + let editing = self.editing.as_deref() == Some(id.as_str()); + let label_rect = Rect::from_min_size( + rect.left_top() + vec2(8.0, -26.0), + vec2(rect.width().min(320.0) - 16.0, 22.0), + ); + let NodeKind::Group { label, .. } = &mut doc.canvas.nodes[index].kind else { + return; + }; + + if editing { + let mut text = label.clone().unwrap_or_default(); + let mut body = scene.new_child(UiBuilder::new().max_rect(label_rect)); + let response = body.add( + TextEdit::singleline(&mut text) + .hint_text("Group label") + .desired_width(label_rect.width()), + ); + if self.focus_editor { + response.request_focus(); + self.focus_editor = false; + } + if text != label.clone().unwrap_or_default() { + doc.begin_change("Edit label"); + if let NodeKind::Group { label, .. } = &mut doc.canvas.nodes[index].kind { + *label = (!text.is_empty()).then_some(text); + } + } + if response.lost_focus() { + self.editing = None; + doc.end_change(); + } + } else { + let text = label.clone().unwrap_or_default(); + if text.is_empty() { + return; + } + let painter = scene.painter(); + let (fill, _) = palette.group_colors(doc.canvas.nodes[index].color.as_ref()); + let chip_fill = theme::blend(fill, palette.surface, 0.5); + let text_color = theme::contrasting_text(chip_fill); + let galley = + painter.layout_no_wrap(text, egui::FontId::proportional(14.0), text_color); + let chip = Rect::from_min_size(label_rect.min, galley.size() + vec2(12.0, 6.0)); + painter.rect_filled(chip, CornerRadius::same(4), chip_fill); + painter.galley(chip.min + vec2(6.0, 3.0), galley, text_color); + } + } + + /// The in-place editor for a text node. + fn show_text_editor( + &mut self, + scene: &mut egui::Ui, + doc: &mut Document, + index: usize, + inner: Rect, + palette: &Palette, + ) { + let NodeKind::Text { text } = &doc.canvas.nodes[index].kind else { + self.editing = None; + return; + }; + let mut buffer = text.clone(); + let mut body = scene.new_child(UiBuilder::new().max_rect(inner)); + body.set_clip_rect(inner.intersect(scene.clip_rect())); + let response = body.add( + TextEdit::multiline(&mut buffer) + .frame(egui::Frame::NONE) + .desired_width(inner.width()) + .desired_rows(1) + .hint_text(RichText::new("Markdown…").color(palette.text_muted)) + .font(egui::FontId::proportional(14.0)), + ); + if self.focus_editor { + response.request_focus(); + self.focus_editor = false; + } + if response.changed() { + doc.begin_change("Edit text"); + if let NodeKind::Text { text } = &mut doc.canvas.nodes[index].kind { + *text = buffer; + } + } + if response.lost_focus() { + self.editing = None; + doc.end_change(); + } + } + + fn paint_selection(&self, ui: &egui::Ui, canvas: &Canvas, palette: &Palette) { + let painter = ui.painter(); + for id in &self.selection.nodes { + let Some(node) = canvas.node(id) else { continue }; + painter.rect_stroke( + node_rect(node).expand(2.5), + CornerRadius::same((NODE_CORNER + 2.0) as u8), + Stroke::new(2.0 / self.to_global.scaling.max(0.2), palette.accent), + StrokeKind::Outside, + ); + } + } + + /// Resize and connect handles for the current selection. Returns `true` + /// when a handle is using the pointer, so node dragging stands down. + fn show_handles(&mut self, ui: &egui::Ui, doc: &mut Document, palette: &Palette) -> bool { + let scale = self.to_global.scaling; + let size = HANDLE_SIZE / scale; + let mut used = false; + + // Connect handles: on the hovered node, or on a single selected node. + // The handles have to stay put while the pointer is down: the moment a + // drag leaves the node, the hover would otherwise move on and take the + // handle (and the gesture that is starting on it) with it. + let pointer_down = ui.input(|i| i.pointer.any_down()); + let pointer = ui + .ctx() + .pointer_latest_pos() + .map(|p| self.to_global.inverse() * p); + self.connect_target = match &self.gesture { + Gesture::Connect { from, .. } => Some(from.clone()), + Gesture::Band { .. } | Gesture::Move { .. } | Gesture::Resize { .. } => None, + _ if pointer_down => self.connect_target.clone(), + // The dots sit just outside the node, so the node counts as hovered + // a little beyond its own box. + _ => pointer + .and_then(|p| self.node_near(&doc.canvas, p, size * 2.0)) + .or_else(|| { + self.selection + .only_node() + .filter(|id| doc.canvas.node(id).is_some_and(|n| !n.is_group())) + .map(str::to_owned) + }), + }; + let connect_node = self + .connect_target + .clone() + .filter(|_| scale > 0.25) + .and_then(|id| doc.canvas.node(&id).map(|node| (id, node_rect(node)))); + if let Some((id, rect)) = connect_node { + for side in Side::ALL { + // Sitting just outside the node keeps the connect dots clear of + // the resize handles on the same midpoints. + let centre = anchor(rect, side) + crate::geometry::normal(side) * size * 0.8; + let handle_rect = Rect::from_center_size(centre, Vec2::splat(size * 1.2)); + let response = ui.interact( + handle_rect, + ui.id().with(("connect", &id, side.label())), + Sense::click_and_drag(), + ); + let hot = response.contains_pointer() + || matches!(&self.gesture, Gesture::Connect { from, side: s } if *from == id && *s == side); + ui.painter().circle_filled( + centre, + size * if hot { 0.55 } else { 0.35 }, + palette.accent, + ); + if response.contains_pointer() { + ui.ctx().set_cursor_icon(CursorIcon::Crosshair); + } + if response.drag_started() { + self.gesture = Gesture::Connect { + from: id.clone(), + side, + }; + } + if response.dragged() || response.drag_stopped() { + used = true; + } + } + } + + // Resize handles, only for a single selected node. + let Some(id) = self.selection.only_node().map(str::to_owned) else { + return used; + }; + let Some(node) = doc.canvas.node(&id) else { + return used; + }; + let rect = node_rect(node); + for handle in Handle::ALL { + let centre = handle.position(rect); + let handle_rect = Rect::from_center_size(centre, Vec2::splat(size)); + let response = ui.interact( + handle_rect, + ui.id().with(("resize", &id, handle as u8)), + Sense::drag(), + ); + ui.painter().rect( + handle_rect, + CornerRadius::same(2), + palette.surface, + Stroke::new(1.5 / scale, palette.accent), + StrokeKind::Inside, + ); + if response.contains_pointer() || response.dragged() { + ui.ctx().set_cursor_icon(handle.cursor()); + } + if response.drag_started() { + self.gesture = Gesture::Resize { + id: id.clone(), + handle, + origin: rect, + accumulated: Vec2::ZERO, + }; + } + if response.dragged() { + used = true; + if let Gesture::Resize { + accumulated, origin, handle, .. + } = &mut self.gesture + { + *accumulated += response.drag_delta(); + let resized = handle.resize(*origin, *accumulated); + let snapped = self.snap_rect(resized); + doc.begin_change("Resize"); + if let Some(node) = doc.canvas.node_mut(&id) { + node.x = snapped.left().round() as i32; + node.y = snapped.top().round() as i32; + node.width = (snapped.width().round() as i32).max(MIN_NODE_SIZE); + node.height = (snapped.height().round() as i32).max(MIN_NODE_SIZE); + } + } + } + if response.drag_stopped() { + used = true; + self.gesture = Gesture::None; + doc.end_change(); + } + } + used + } + + /// Rubber band and in-progress edge. + fn paint_gesture_overlay(&self, ui: &egui::Ui, canvas: &Canvas, palette: &Palette) { + let painter = ui.painter(); + match &self.gesture { + Gesture::Band { start, .. } => { + let Some(current) = ui + .ctx() + .pointer_latest_pos() + .map(|p| self.to_global.inverse() * p) + else { + return; + }; + let rect = Rect::from_two_pos(*start, current); + painter.rect_filled( + rect, + CornerRadius::ZERO, + palette.accent.gamma_multiply(0.12), + ); + painter.rect_stroke( + rect, + CornerRadius::ZERO, + Stroke::new(1.0 / self.to_global.scaling, palette.accent), + StrokeKind::Inside, + ); + } + Gesture::Connect { from, side } => { + let Some(node) = canvas.node(from) else { return }; + let Some(pointer) = ui + .ctx() + .pointer_latest_pos() + .map(|p| self.to_global.inverse() * p) + else { + return; + }; + let target = self.node_at(canvas, pointer); + let (end, to_side) = match target.as_ref().and_then(|id| canvas.node(id)) { + Some(target) if target.id != *from => { + let side = nearest_side(node_rect(target), pointer); + (anchor(node_rect(target), side), side) + } + _ => (pointer, side.opposite()), + }; + let curve = curve_between(anchor(node_rect(node), *side), *side, end, to_side); + painter.add(Shape::CubicBezier(epaint::CubicBezierShape::from_points_stroke( + curve, + false, + Color32::TRANSPARENT, + Stroke::new(2.0, palette.accent), + ))); + paint_arrow(painter, curve[3], bezier_tangent(curve, 1.0), 2.0, palette.accent); + } + _ => {} + } + } + + // -- gesture resolution ---------------------------------------------- + + #[allow(clippy::too_many_arguments)] + fn handle_interactions( + &mut self, + ui: &egui::Ui, + doc: &mut Document, + background: &egui::Response, + interactions: NodeInteractions, + handle_used: bool, + output: &mut ViewOutput, + ) { + let shift = ui.input(|i| i.modifiers.shift); + let pointer = ui + .ctx() + .pointer_latest_pos() + .map(|p| self.to_global.inverse() * p); + + // Which edge is under the pointer? Nodes win over edges. + self.hovered_edge = match (pointer, interactions.hovered.is_none() && !handle_used) { + (Some(p), true) => self.edge_at(&doc.canvas, p), + _ => None, + }; + if self.hovered_edge.is_some() { + ui.ctx().set_cursor_icon(CursorIcon::PointingHand); + } + + // -- clicks on nodes + if let Some(id) = &interactions.clicked { + if shift { + self.selection.toggle_node(id); + } else if !self.selection.has_node(id) { + self.selection.set_node(id); + } + if self.editing.as_deref() != Some(id.as_str()) { + self.editing = None; + } + } + + if let Some(id) = &interactions.double_clicked { + self.activate_node(doc, id, output); + } + + // -- dragging nodes + if let Some(id) = &interactions.drag_started + && !handle_used + && !matches!(self.gesture, Gesture::Connect { .. }) + { + if !self.selection.has_node(id) && shift { + self.selection.toggle_node(id); + } else if !self.selection.has_node(id) { + self.selection.set_node(id); + } + self.editing = None; + let ids = self.with_group_members(&doc.canvas, &self.selection.nodes.clone()); + let origin = ids + .iter() + .filter_map(|id| doc.canvas.node(id).map(|n| (n.id.clone(), (n.x, n.y)))) + .collect(); + self.gesture = Gesture::Move { + origin, + accumulated: Vec2::ZERO, + }; + } + if interactions.dragging + && !handle_used + && let Gesture::Move { + origin, + accumulated, + } = &mut self.gesture + { + *accumulated += ui.input(|i| i.pointer.delta()) / self.to_global.scaling; + let delta = *accumulated; + let origin = origin.clone(); + doc.begin_change("Move"); + for (id, (x, y)) in &origin { + if let Some(node) = doc.canvas.node_mut(id) { + let (nx, ny) = self.snap_position(*x as f32 + delta.x, *y as f32 + delta.y); + node.x = nx; + node.y = ny; + } + } + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } + if interactions.drag_stopped && matches!(self.gesture, Gesture::Move { .. }) { + self.gesture = Gesture::None; + doc.end_change(); + } + + // -- background gestures + if background.drag_started_by(PointerButton::Primary) + && !handle_used + && let Some(start) = pointer + { + if !shift { + self.selection.clear(); + } + self.editing = None; + self.gesture = Gesture::Band { start, add: shift }; + } + if let Gesture::Band { start, add } = self.gesture { + if let Some(current) = pointer { + let rect = Rect::from_two_pos(start, current); + let hits: Vec = doc + .canvas + .nodes + .iter() + .filter(|n| node_rect(n).intersects(rect)) + .map(|n| n.id.clone()) + .collect(); + if !add { + self.selection.nodes = hits; + } else { + for id in hits { + if !self.selection.has_node(&id) { + self.selection.nodes.push(id); + } + } + } + } + if background.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + self.gesture = Gesture::None; + } + } + + // -- finishing a new edge + if let Gesture::Connect { from, side } = self.gesture.clone() { + let released = ui.input(|i| i.pointer.any_released()); + if released { + self.gesture = Gesture::None; + if let Some(pointer) = pointer { + self.finish_connection(doc, &from, side, pointer, output); + } + } + } + + // -- clicks on empty canvas / edges + if background.clicked() { + match self.hovered_edge.clone() { + Some(edge) => { + if shift { + self.selection.toggle_edge(&edge); + } else { + self.selection.set_edge(&edge); + } + } + None => { + if !shift { + self.selection.clear(); + } + self.editing = None; + } + } + } + if background.double_clicked() + && self.hovered_edge.is_none() + && let Some(at) = pointer + { + let id = self.insert_node(doc, NodeKind::Text { text: String::new() }, at); + self.edit_node(&id); + output.status = Some("Added a text node".to_owned()); + } + } + + /// Opens a file/link node, or starts editing a text node. + fn activate_node(&mut self, doc: &mut Document, id: &str, output: &mut ViewOutput) { + let Some(node) = doc.canvas.node(id) else { return }; + match &node.kind { + NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id), + NodeKind::File { file, .. } => output.open_target = Some(file.clone()), + NodeKind::Link { url } => output.open_target = Some(url.clone()), + NodeKind::Unknown { .. } => {} + } + } + + fn finish_connection( + &mut self, + doc: &mut Document, + from: &str, + side: Side, + pointer: Pos2, + output: &mut ViewOutput, + ) { + let target = self.node_at(&doc.canvas, pointer).filter(|id| id != from); + match target { + Some(target) => { + let to_side = doc + .canvas + .node(&target) + .map(|n| nearest_side(node_rect(n), pointer)) + .unwrap_or(side.opposite()); + let id = doc.canvas.fresh_id(); + doc.change("Connect"); + doc.canvas + .edges + .push(Edge::new(id.clone(), from, side, target, to_side)); + self.selection.set_edge(&id); + output.status = Some("Connected two nodes".to_owned()); + } + None => { + // Dropping on empty canvas creates the node the edge points to. + let new_id = self.insert_node(doc, NodeKind::Text { text: String::new() }, pointer); + let edge_id = doc.canvas.fresh_id(); + doc.canvas + .edges + .push(Edge::new(edge_id, from, side, &new_id, side.opposite())); + self.edit_node(&new_id); + output.status = Some("Added a connected text node".to_owned()); + } + } + } + + /// Topmost node containing `point`; groups only when nothing else matches. + fn node_at(&self, canvas: &Canvas, point: Pos2) -> Option { + canvas + .nodes + .iter() + .rev() + .filter(|n| !n.is_group() && node_rect(n).contains(point)) + .map(|n| n.id.clone()) + .next() + .or_else(|| { + canvas + .nodes + .iter() + .rev() + .find(|n| n.is_group() && node_rect(n).contains(point)) + .map(|n| n.id.clone()) + }) + } + + /// Topmost non-group node within `margin` of `point`, which is how the + /// connect dots stay reachable just outside a node. + fn node_near(&self, canvas: &Canvas, point: Pos2, margin: f32) -> Option { + canvas + .nodes + .iter() + .rev() + .find(|n| !n.is_group() && node_rect(n).expand(margin).contains(point)) + .map(|n| n.id.clone()) + } + + fn edge_at(&self, canvas: &Canvas, point: Pos2) -> Option { + let radius = EDGE_HIT_RADIUS / self.to_global.scaling; + canvas + .edges + .iter() + .filter_map(|edge| { + let curve = curve_of(canvas, edge)?; + let distance = distance_to_curve(curve, point); + (distance <= radius).then(|| (edge.id.clone(), distance)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(id, _)| id) + } + + fn snap_position(&self, x: f32, y: f32) -> (i32, i32) { + if self.snap_to_grid { + ( + ((x / GRID).round() * GRID) as i32, + ((y / GRID).round() * GRID) as i32, + ) + } else { + (x.round() as i32, y.round() as i32) + } + } + + fn snap_rect(&self, rect: Rect) -> Rect { + if !self.snap_to_grid { + return rect; + } + let snap = |v: f32| (v / GRID).round() * GRID; + Rect::from_min_max( + pos2(snap(rect.left()), snap(rect.top())), + pos2(snap(rect.right()), snap(rect.bottom())), + ) + } +} + +/// Nodes copied for pasting. +#[derive(Default, Clone)] +pub struct Clipboard { + pub nodes: Vec, + pub edges: Vec, +} + +impl Clipboard { + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } +} + +/// Nodes are drawn in two passes so that edges end up between the groups and +/// the nodes they connect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pass { + Groups, + Nodes, +} + +/// What happened to nodes during one frame. +#[derive(Default)] +struct NodeInteractions { + clicked: Option, + double_clicked: Option, + drag_started: Option, + dragging: bool, + drag_stopped: bool, + hovered: Option, +} + +impl NodeInteractions { + /// Later passes are on top, so their result wins. + fn merge(&mut self, other: Self) { + self.clicked = other.clicked.or(self.clicked.take()); + self.double_clicked = other.double_clicked.or(self.double_clicked.take()); + self.drag_started = other.drag_started.or(self.drag_started.take()); + self.dragging |= other.dragging; + self.drag_stopped |= other.drag_stopped; + self.hovered = other.hovered.or(self.hovered.take()); + } +} + +// --------------------------------------------------------------------------- +// Free functions +// --------------------------------------------------------------------------- + +/// The curve an edge is drawn along, or `None` if it dangles. +pub fn curve_of(canvas: &Canvas, edge: &Edge) -> Option<[Pos2; 4]> { + let from = node_rect(canvas.node(&edge.from_node)?); + let to = node_rect(canvas.node(&edge.to_node)?); + let (auto_from, auto_to) = auto_sides(from, to); + Some(edge_curve( + from, + edge.from_side.unwrap_or(auto_from), + to, + edge.to_side.unwrap_or(auto_to), + )) +} + +/// The side of `rect` closest to `point`. +pub fn nearest_side(rect: Rect, point: Pos2) -> Side { + let candidates = [ + (Side::Top, (point - anchor(rect, Side::Top)).length()), + (Side::Right, (point - anchor(rect, Side::Right)).length()), + (Side::Bottom, (point - anchor(rect, Side::Bottom)).length()), + (Side::Left, (point - anchor(rect, Side::Left)).length()), + ]; + candidates + .into_iter() + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(side, _)| side) + .unwrap_or(Side::Top) +} + +/// Draws a group's `background` image with the fitting its `backgroundStyle` +/// asks for. An image that is still loading or missing simply does not appear. +fn paint_group_background( + ui: &egui::Ui, + rect: Rect, + background: &str, + style: Option, + base_dir: Option<&std::path::Path>, +) { + use crate::model::BackgroundStyle; + use eframe::egui::{TextureOptions, load::SizeHint, load::TexturePoll}; + use eframe::epaint::textures::TextureWrapMode; + + let path = nodes::resolve_path(base_dir, background); + let repeat = style == Some(BackgroundStyle::Repeat); + let options = TextureOptions { + wrap_mode: if repeat { + TextureWrapMode::Repeat + } else { + TextureWrapMode::ClampToEdge + }, + ..TextureOptions::LINEAR + }; + let Ok(TexturePoll::Ready { texture }) = ui.ctx().try_load_texture( + &nodes::file_uri(&path), + options, + SizeHint::Size { + width: rect.width().max(1.0) as u32, + height: rect.height().max(1.0) as u32, + maintain_aspect_ratio: true, + }, + ) else { + return; + }; + if texture.size.x <= 0.0 || texture.size.y <= 0.0 { + return; + } + + let full = Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)); + let (target, uv) = match style { + Some(BackgroundStyle::Repeat) => { + let tiles = rect.size() / texture.size; + (rect, Rect::from_min_size(Pos2::ZERO, tiles)) + } + Some(BackgroundStyle::Ratio) => { + // Contain: the whole image, centred, letterboxed. + let scale = (rect.size() / texture.size).min_elem(); + ( + Rect::from_center_size(rect.center(), texture.size * scale), + full, + ) + } + // The spec leaves the default open; covering the box is what the + // reference apps do, so crop the overflowing axis instead of squashing. + _ => { + let scale = (rect.size() / texture.size).max_elem(); + let covered = texture.size * scale; + let crop = Vec2::new( + (1.0 - rect.width() / covered.x).max(0.0) / 2.0, + (1.0 - rect.height() / covered.y).max(0.0) / 2.0, + ); + ( + rect, + Rect::from_min_max(pos2(crop.x, crop.y), pos2(1.0 - crop.x, 1.0 - crop.y)), + ) + } + }; + ui.painter() + .image(texture.id, target, uv, Color32::WHITE); +} + +fn paint_arrow(painter: &egui::Painter, tip: Pos2, direction: Vec2, width: f32, color: Color32) { + let size = 4.0 + width * 2.0; + let back = -direction * size; + let side = vec2(-direction.y, direction.x) * (size * 0.45); + painter.add(Shape::convex_polygon( + vec![tip, tip + back + side, tip + back - side], + color, + Stroke::NONE, + )); +} + +fn paint_edge_label( + painter: &egui::Painter, + at: Pos2, + label: &str, + palette: &Palette, + accent: Color32, +) { + let galley = painter.layout_no_wrap( + label.to_owned(), + egui::FontId::proportional(12.0), + palette.text, + ); + let rect = Rect::from_center_size(at, galley.size() + vec2(10.0, 5.0)); + painter.rect( + rect, + CornerRadius::same(4), + palette.surface, + Stroke::new(1.0, accent.gamma_multiply(0.5)), + StrokeKind::Inside, + ); + painter.galley(rect.min + vec2(5.0, 2.5), galley, palette.text); +} + +fn shadow(palette: &Palette) -> Color32 { + if palette.dark { + Color32::from_black_alpha(60) + } else { + Color32::from_black_alpha(18) + } +} + +/// Default size for a newly created node, following the reference apps. +pub fn default_size(kind: &NodeKind) -> (i32, i32) { + match kind { + NodeKind::Text { .. } => (250, 120), + NodeKind::File { .. } | NodeKind::Link { .. } => (400, 400), + NodeKind::Group { .. } => (500, 400), + NodeKind::Unknown { .. } => (250, 120), + } +} + +/// "2 nodes and 1 edge", skipping the zero parts. +fn count_phrase(parts: &[(usize, &str)]) -> String { + let words: Vec = parts + .iter() + .filter(|(count, _)| *count > 0) + .map(|(count, noun)| { + if *count == 1 { + format!("1 {noun}") + } else { + format!("{count} {noun}s") + } + }) + .collect(); + match words.len() { + 0 => "nothing".to_owned(), + 1 => words[0].clone(), + _ => words.join(" and "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Canvas; + + /// Runs one egui pass over the canvas with the given input events. + fn pass( + ctx: &egui::Context, + view: &mut CanvasView, + doc: &mut Document, + screen: Rect, + events: Vec, + ) { + let palette = Palette::dark(); + let input = egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }; + ctx.run_ui(input, |ui| { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| { + view.show(ui, doc, &palette); + }); + }) + .drop_without_applying_deltas(); + } + + fn click(at: Pos2) -> egui::Event { + egui::Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + } + } + + fn release(at: Pos2) -> egui::Event { + egui::Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + } + } + + #[test] + fn framing_waits_for_the_real_viewport() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + let mut view = CanvasView::new(); + + // Asked for before the first frame, when the viewport is still unknown. + view.zoom_to_fit(&doc.canvas); + assert_eq!(view.pending_fit, Some(PendingFit::Origin)); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert_eq!(view.pending_fit, None); + // The origin, not the top left corner, sits in the middle. + assert!( + view.centre_in_canvas().distance(Pos2::ZERO) < 1.0, + "empty canvas is centred on {:?}", + view.centre_in_canvas() + ); + + // Same for a canvas with content: the nodes end up in the middle. + let mut view = CanvasView::new(); + doc.canvas = canvas_with_two_nodes(); + view.zoom_to_fit(&doc.canvas); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + let bounds = bounds_of(doc.canvas.nodes.iter().map(node_rect)).unwrap(); + assert!( + view.centre_in_canvas().distance(bounds.center()) < 1.0, + "content is centred on {:?}, expected {:?}", + view.centre_in_canvas(), + bounds.center() + ); + } + + #[test] + fn the_background_still_responds_far_from_where_the_view_started() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.selection.set_node("a"); + // Panned well away from the canvas origin. + view.to_global = TSTransform::from_translation(vec2(5000.0, 4000.0)); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(600.0, 450.0); + // Three passes: egui hit-tests against the rectangles of the previous + // one, and a click is a press followed by a release. + pass(&ctx, &mut view, &mut doc, screen, vec![egui::Event::PointerMoved(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + + // Clicking empty canvas clears the selection. + assert!( + view.selection.is_empty(), + "the click never reached the background" + ); + } + + fn canvas_with_two_nodes() -> Canvas { + let mut canvas = Canvas::default(); + canvas.nodes.push(Node::new( + "a", + NodeKind::Text { text: "a".into() }, + 0, + 0, + 100, + 100, + )); + canvas.nodes.push(Node::new( + "b", + NodeKind::Text { text: "b".into() }, + 300, + 0, + 100, + 100, + )); + canvas + } + + #[test] + fn selection_tracks_nodes_and_edges() { + let mut selection = Selection::default(); + selection.set_node("a"); + assert_eq!(selection.only_node(), Some("a")); + selection.toggle_node("b"); + assert_eq!(selection.len(), 2); + assert_eq!(selection.only_node(), None); + selection.toggle_node("b"); + assert!(selection.has_node("a")); + selection.set_edge("e"); + assert!(selection.nodes.is_empty()); + assert_eq!(selection.only_edge(), Some("e")); + } + + #[test] + fn selection_drops_ids_that_no_longer_exist() { + let canvas = canvas_with_two_nodes(); + let mut selection = Selection { + nodes: vec!["a".into(), "gone".into()], + edges: vec!["nope".into()], + }; + selection.prune(&canvas); + assert_eq!(selection.nodes, vec!["a".to_owned()]); + assert!(selection.edges.is_empty()); + } + + #[test] + fn handles_resize_from_the_right_corner() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); + let grown = Handle::SouthEast.resize(rect, vec2(20.0, 10.0)); + assert_eq!(grown.min, rect.min); + assert_eq!(grown.max, pos2(120.0, 110.0)); + + let moved = Handle::NorthWest.resize(rect, vec2(20.0, 10.0)); + assert_eq!(moved.min, pos2(20.0, 10.0)); + assert_eq!(moved.max, rect.max); + + let side = Handle::East.resize(rect, vec2(20.0, 999.0)); + assert_eq!(side.height(), rect.height()); + } + + #[test] + fn nearest_side_picks_the_closest_anchor() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); + assert_eq!(nearest_side(rect, pos2(50.0, -30.0)), Side::Top); + assert_eq!(nearest_side(rect, pos2(140.0, 50.0)), Side::Right); + assert_eq!(nearest_side(rect, pos2(50.0, 130.0)), Side::Bottom); + assert_eq!(nearest_side(rect, pos2(-40.0, 50.0)), Side::Left); + } + + #[test] + fn dangling_edges_have_no_curve() { + let canvas = canvas_with_two_nodes(); + let good = Edge::new("e", "a", Side::Right, "b", Side::Left); + assert!(curve_of(&canvas, &good).is_some()); + let bad = Edge::new("e", "a", Side::Right, "ghost", Side::Left); + assert!(curve_of(&canvas, &bad).is_none()); + } + + #[test] + fn hit_testing_finds_nodes_and_edges() { + let view = CanvasView::new(); + let mut canvas = canvas_with_two_nodes(); + canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + assert_eq!(view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), Some("a")); + assert_eq!(view.node_at(&canvas, pos2(-5.0, 50.0)), None); + // The edge runs from (100,50) to (300,50). + assert_eq!(view.edge_at(&canvas, pos2(200.0, 50.0)).as_deref(), Some("e")); + assert_eq!(view.edge_at(&canvas, pos2(200.0, 200.0)), None); + } + + #[test] + fn groups_are_only_hit_when_nothing_covers_them() { + let mut canvas = canvas_with_two_nodes(); + canvas.nodes.insert( + 0, + Node::new( + "g", + NodeKind::Group { + label: None, + background: None, + background_style: None, + }, + -50, + -50, + 500, + 300, + ), + ); + let view = CanvasView::new(); + assert_eq!(view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), Some("a")); + assert_eq!(view.node_at(&canvas, pos2(200.0, 200.0)).as_deref(), Some("g")); + } + + #[test] + fn moving_a_group_takes_its_members_along() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.nodes.insert( + 0, + Node::new( + "g", + NodeKind::Group { + label: None, + background: None, + background_style: None, + }, + -50, + -50, + 600, + 300, + ), + ); + let mut view = CanvasView::new(); + view.select_node("g"); + view.nudge(&mut doc, vec2(10.0, 0.0)); + assert_eq!(doc.canvas.node("g").unwrap().x, -40); + assert_eq!(doc.canvas.node("a").unwrap().x, 10); + assert_eq!(doc.canvas.node("b").unwrap().x, 310); + } + + #[test] + fn copy_and_paste_creates_new_ids_and_keeps_inner_edges() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + view.select_all(&doc.canvas); + + let clipboard = view.copy_selection(&doc.canvas).unwrap(); + assert_eq!(clipboard.nodes.len(), 2); + assert_eq!(clipboard.edges.len(), 1); + + view.paste(&mut doc, &clipboard, vec2(20.0, 20.0)); + assert_eq!(doc.canvas.nodes.len(), 4); + assert_eq!(doc.canvas.edges.len(), 2); + let ids: std::collections::HashSet<&str> = + doc.canvas.nodes.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids.len(), 4, "pasted nodes need fresh ids"); + // The pasted edge joins the pasted nodes, not the originals. + let pasted = doc.canvas.edges.last().unwrap(); + assert!(view.selection.has_node(&pasted.from_node)); + assert!(view.selection.has_node(&pasted.to_node)); + } + + #[test] + fn deleting_a_node_removes_its_edges_and_reports_it() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + view.select_node("a"); + let message = view.delete_selection(&mut doc).unwrap(); + assert_eq!(doc.canvas.nodes.len(), 1); + assert!(doc.canvas.edges.is_empty()); + assert!(message.contains("1 node"), "{message}"); + assert!(view.selection.is_empty()); + } + + #[test] + fn z_order_commands_reorder_the_file() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.select_node("a"); + view.raise_selection(&mut doc, true); + assert_eq!(doc.canvas.nodes.last().unwrap().id, "a"); + view.raise_selection(&mut doc, false); + assert_eq!(doc.canvas.nodes.first().unwrap().id, "a"); + } + + #[test] + fn inserting_a_group_puts_it_behind_the_others() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.insert_node( + &mut doc, + NodeKind::Group { + label: Some("G".into()), + background: None, + background_style: None, + }, + pos2(0.0, 0.0), + ); + assert!(doc.canvas.nodes[0].is_group()); + } + + #[test] + fn counts_read_naturally() { + assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node"); + assert_eq!(count_phrase(&[(2, "node"), (3, "edge")]), "2 nodes and 3 edges"); + assert_eq!(count_phrase(&[(0, "node")]), "nothing"); + } +}