From f35e5c82e5d23f74d2dbcd81541ff4041a25862d Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 9 Sep 2026 21:19:44 +0200 Subject: [PATCH] Version 1 --- .gitignore | 1 + Cargo.lock | 4571 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 16 + README.md | 120 ++ docs/screenshot.png | Bin 0 -> 166962 bytes examples/tour.canvas | 15 + src/app.rs | 1050 ++++++++++ src/document.rs | 266 +++ src/geometry.rs | 216 ++ src/inspector.rs | 817 ++++++++ src/main.rs | 45 + src/markdown.rs | 750 +++++++ src/model.rs | 755 +++++++ src/nodes.rs | 352 ++++ src/theme.rs | 178 ++ src/view.rs | 1996 ++++++++++++++++++ 16 files changed, 11148 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docs/screenshot.png create mode 100644 examples/tour.canvas create mode 100644 src/app.rs create mode 100644 src/document.rs create mode 100644 src/geometry.rs create mode 100644 src/inspector.rs create mode 100644 src/main.rs create mode 100644 src/markdown.rs create mode 100644 src/model.rs create mode 100644 src/nodes.rs create mode 100644 src/theme.rs create mode 100644 src/view.rs 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 0000000000000000000000000000000000000000..1dbb6639027174584759c6db58be1b4f8c7bca0e GIT binary patch literal 166962 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

e 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, + 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"); + } +}