From 19e439b3b8ffe06fa95853db1cae0555a77f55c2 Mon Sep 17 00:00:00 2001 From: Urho Laukkarinen Date: Fri, 27 May 2022 22:46:17 +0300 Subject: [PATCH] Initial commit --- .gitignore | 2 + .vscode/tasks.json | 21 + Cargo.lock | 2073 +++++++++++++++++++++++ Cargo.toml | 7 + LICENSE | 21 + README.md | 27 + crates/nodio-api/Cargo.toml | 11 + crates/nodio-api/src/lib.rs | 11 + crates/nodio-app/Cargo.toml | 18 + crates/nodio-app/fonts/Lato-Regular.ttf | Bin 0 -> 75152 bytes crates/nodio-app/src/main.rs | 431 +++++ crates/nodio-app/src/slider.rs | 688 ++++++++ crates/nodio-core/Cargo.toml | 12 + crates/nodio-core/src/lib.rs | 78 + crates/nodio-core/src/result.rs | 20 + crates/nodio-gui-nodes/Cargo.toml | 14 + crates/nodio-gui-nodes/src/lib.rs | 1397 +++++++++++++++ crates/nodio-gui-nodes/src/link.rs | 248 +++ crates/nodio-gui-nodes/src/node.rs | 178 ++ crates/nodio-gui-nodes/src/pin.rs | 113 ++ crates/nodio-gui-nodes/src/style.rs | 300 ++++ crates/nodio-win32/Cargo.toml | 39 + crates/nodio-win32/src/com.rs | 43 + crates/nodio-win32/src/context.rs | 649 +++++++ crates/nodio-win32/src/custom.rs | 950 +++++++++++ crates/nodio-win32/src/device.rs | 335 ++++ crates/nodio-win32/src/enumerator.rs | 88 + crates/nodio-win32/src/lib.rs | 25 + crates/nodio-win32/src/loopback.rs | 412 +++++ crates/nodio-win32/src/node.rs | 16 + crates/nodio-win32/src/render.rs | 70 + crates/nodio-win32/src/session.rs | 289 ++++ docs/screenshot.png | Bin 0 -> 29111 bytes src/main.rs | 1 + 34 files changed, 8587 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crates/nodio-api/Cargo.toml create mode 100644 crates/nodio-api/src/lib.rs create mode 100644 crates/nodio-app/Cargo.toml create mode 100644 crates/nodio-app/fonts/Lato-Regular.ttf create mode 100644 crates/nodio-app/src/main.rs create mode 100644 crates/nodio-app/src/slider.rs create mode 100644 crates/nodio-core/Cargo.toml create mode 100644 crates/nodio-core/src/lib.rs create mode 100644 crates/nodio-core/src/result.rs create mode 100644 crates/nodio-gui-nodes/Cargo.toml create mode 100644 crates/nodio-gui-nodes/src/lib.rs create mode 100644 crates/nodio-gui-nodes/src/link.rs create mode 100644 crates/nodio-gui-nodes/src/node.rs create mode 100644 crates/nodio-gui-nodes/src/pin.rs create mode 100644 crates/nodio-gui-nodes/src/style.rs create mode 100644 crates/nodio-win32/Cargo.toml create mode 100644 crates/nodio-win32/src/com.rs create mode 100644 crates/nodio-win32/src/context.rs create mode 100644 crates/nodio-win32/src/custom.rs create mode 100644 crates/nodio-win32/src/device.rs create mode 100644 crates/nodio-win32/src/enumerator.rs create mode 100644 crates/nodio-win32/src/lib.rs create mode 100644 crates/nodio-win32/src/loopback.rs create mode 100644 crates/nodio-win32/src/node.rs create mode 100644 crates/nodio-win32/src/render.rs create mode 100644 crates/nodio-win32/src/session.rs create mode 100644 docs/screenshot.png create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40d9aca --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/.idea \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f6f31f7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "cargo", + "command": "run", + "problemMatcher": [ + "$rustc" + ], + "args": [ + "-p", + "nodio-app" + ], + "label": "rust: cargo run -p nodio-app", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a077ac5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2073 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61caed9aec6daeee1ea38ccf5fb225e4f96c1eeead1b4a5c267324a63cf02326" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13739d7177fbd22bb0ed28badfff9f372f8bef46c863db4e1c6248f6b223b6e" + +[[package]] +name = "addr2line" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "serde", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_glue" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000444226fcff248f2bc4c7625be32c63caccfecc2723a2b9f78a7487a49c407" + +[[package]] +name = "arboard" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6045ca509e4abacde2b884ac4618a51d0c017b5d85a3ee84a7226eb33b3154a9" +dependencies = [ + "clipboard-win", + "log", + "objc", + "objc-foundation", + "objc_id", + "once_cell", + "parking_lot 0.12.0", + "thiserror", + "winapi", + "x11rb", +] + +[[package]] +name = "atomic_refcell" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b5e5f48b927f04e952dedc932f31995a65a0bf65ec971c74436e51bf6e970d" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61" +dependencies = [ + "addr2line", + "cc", + "cfg-if 1.0.0", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" + +[[package]] +name = "bytemuck" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdead85bdec19c194affaeeb670c0e41fe23de31459efd1c174d049269cf02cc" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562e382481975bc61d11275ac5e62a19abd00b0547d99516a415336f183dcd0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "calloop" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf2eec61efe56aa1e813f5126959296933cf0700030e4314786c48779a66ab82" +dependencies = [ + "log", + "nix", +] + +[[package]] +name = "cc" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[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 = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3e1238132dc01f081e1cbb9dace14e5ef4c3a51ee244bd982275fb514605db" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + +[[package]] +name = "cocoa" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63902e9223530efb4e26ccd0cf55ec30d592d3b42e21a28defc42a9586e832" +dependencies = [ + "bitflags", + "block", + "cocoa-foundation", + "core-foundation 0.9.3", + "core-graphics 0.22.3", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ade49b65d560ca58c403a479bb396592b155c0185eada742ee323d1d68d6318" +dependencies = [ + "bitflags", + "block", + "core-foundation 0.9.3", + "core-graphics-types", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a604e93b79d1808327a6fca85a6f2d69de66461e7620f5a4cbf5fb4d1d7c948" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys 0.8.3", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags", + "core-foundation 0.7.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags", + "core-foundation 0.9.3", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b" +dependencies = [ + "bitflags", + "core-foundation 0.9.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "libc", + "objc", +] + +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + +[[package]] +name = "darling" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d720b8683f8dd83c65155f0530560cba68cd2bf395f6513a483caee57ff7f4" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a340f241d2ceed1deb47ae36c4144b2707ec7dd0b649f894cb39bb595986324" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c41b3b7352feb3211a0d743dc5700a4e3b60f51bd2b368892d1e0f9a95f44b" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if 1.0.0", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1b7517328c04c2aa68422fc60a41b92208182142ed04a25879c26c8f878794" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" + +[[package]] +name = "eframe" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fa97a8188c36261ea162e625dbb23599f67b60777b462b834fe38161b81dce" +dependencies = [ + "bytemuck", + "directories-next", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "js-sys", + "percent-encoding", + "ron", + "serde", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winit", +] + +[[package]] +name = "egui" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb095a8b9feb9b7ff8f00b6776dffcef059538a3f4a91238e03c900e9c9ad9a2" +dependencies = [ + "ahash", + "epaint", + "nohash-hasher", + "ron", + "serde", + "tracing", +] + +[[package]] +name = "egui-toast" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584414aff3b4345d86c50e68f96d76a824324c8d3260b70b11da1dd0dcdd8f26" +dependencies = [ + "egui", +] + +[[package]] +name = "egui-winit" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b040afd583fd95a9b9578d4399214a13d948ed26bc0ff7cc0104502501f34e68" +dependencies = [ + "arboard", + "egui", + "instant", + "serde", + "tracing", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43ed7ec7199907ab5853c3bb3883ae1e741ab540aa127a798a60b7bdb906f1" +dependencies = [ + "bytemuck", + "egui", + "glow", + "memoffset", + "tracing", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "emath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c223f58c7e38abe1770f367b969f1b3fbd4704b67666bcb65dbb1adb0980ba72" +dependencies = [ + "bytemuck", + "serde", +] + +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "epaint" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c29567088888e8ac3e8f61bbb2ddc820207ebb8d69eefde5bcefa06d65e4e89" +dependencies = [ + "ab_glyph", + "ahash", + "atomic_refcell", + "bytemuck", + "emath", + "nohash-hasher", + "parking_lot 0.12.0", + "serde", +] + +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + +[[package]] +name = "fixedbitset" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279fb028e20b3c4c320317955b77c5e0c9701f05a1d309905d6fc702cdc5053e" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" + +[[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 = "glow" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bd5877156a19b8ac83a29b2306fe20537429d318f3ff0a1a2119f8d9c61919" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ea9dbe544bc8a657c4c4a798c2d16cd01b549820e47657297549d28371f6d2" +dependencies = [ + "android_glue", + "cgl", + "cocoa", + "core-foundation 0.9.3", + "glutin_egl_sys", + "glutin_emscripten_sys", + "glutin_gles2_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "lazy_static", + "libloading", + "log", + "objc", + "osmesa-sys", + "parking_lot 0.11.2", + "wayland-client", + "wayland-egl", + "winapi", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2abb6aa55523480c4adc5a56bbaa249992e2dddb2fc63dc96e04a3355364c211" +dependencies = [ + "gl_generator", + "winapi", +] + +[[package]] +name = "glutin_emscripten_sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80de4146df76e8a6c32b03007bc764ff3249dcaeb4f675d68a06caf1bac363f1" + +[[package]] +name = "glutin_gles2_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094e708b730a7c8a1954f4f8a31880af00eb8a1c5b5bf85d28a0a3c6d69103" +dependencies = [ + "gl_generator", + "objc", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e393c8fc02b807459410429150e9c4faffdb312d59b8c038566173c81991351" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5951a1569dbab865c6f2a863efafff193a93caf05538d193e9e3816d21696" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "jni" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e74d72e0f9b65b5b4ca49a346af3976df0f9c61d550727f349ecd559f251a26c" + +[[package]] +name = "libloading" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbc0f03f9a775e9f6aed295c6a1ba2253c5757a9e03d55c6caa46a681abcddd" +dependencies = [ + "cfg-if 1.0.0", + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "memmap2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b6c2ebff6180198788f5db08d7ce3bc1d0b617176678831a7510825973e357" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "ndk" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d868f654c72e75f8687572699cdabe755f03effbb62542768e995d5b8d699d" +dependencies = [ + "bitflags", + "jni-sys", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-glue" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc291b8de2095cba8dab7cf381bf582ff4c17a09acf854c32e46545b08085d28" +dependencies = [ + "lazy_static", + "libc", + "log", + "ndk", + "ndk-macro", + "ndk-sys", +] + +[[package]] +name = "ndk-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df7ac00c4672f9d5aece54ee3347520b7e20f158656c7db2e6de01902eb7a6c" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ndk-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags", + "cc", + "cfg-if 1.0.0", + "libc", + "memoffset", +] + +[[package]] +name = "nodio" +version = "0.1.0" + +[[package]] +name = "nodio-api" +version = "0.1.0" +dependencies = [ + "nodio-core", + "nodio-win32", + "parking_lot 0.12.0", +] + +[[package]] +name = "nodio-app" +version = "0.1.0" +dependencies = [ + "eframe", + "egui-toast", + "indexmap", + "log", + "nodio-api", + "nodio-core", + "nodio-gui-nodes", + "parking_lot 0.12.0", + "pretty_env_logger", + "serde_json", +] + +[[package]] +name = "nodio-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "nodio-gui-nodes" +version = "0.1.0" +dependencies = [ + "derivative", + "egui", + "indexmap", + "log", + "uuid", +] + +[[package]] +name = "nodio-win32" +version = "0.1.0" +dependencies = [ + "log", + "nodio-core", + "notify-thread", + "parking_lot 0.12.0", + "pollster", + "widestring 1.0.0-beta.1", + "windows", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" +dependencies = [ + "memchr", + "minimal-lexical", + "version_check", +] + +[[package]] +name = "notify-thread" +version = "0.1.0" +source = "git+https://github.com/urholaukkarinen/notify-thread.git#babe494889f65542500261d2fe394e95a072954a" + +[[package]] +name = "ntapi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f" +dependencies = [ + "winapi", +] + +[[package]] +name = "num_enum" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720d3ea1055e4e4574c0c0b0f8c3fd4f24c4cdaf465948206dea090b57b526ad" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d992b768490d7fe0d8586d9b5745f6c49f557da6d81dc982b1d167ad4edbb21" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.28.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42c982f2d955fac81dd7e1d0e1426a7d702acd9c98d19ab01083a6a0328c424" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" + +[[package]] +name = "osmesa-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88cfece6e95d2e717e0872a7f53a8684712ad13822a7979bc760b9c77ec0013b" +dependencies = [ + "shared_library", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef05f2882a8b3e7acc10c153ade2631f7bfc8ce00d2bf3fb8f4e9d2ae6ea5c3" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.3", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "backtrace", + "cfg-if 1.0.0", + "libc", + "petgraph", + "redox_syscall", + "smallvec", + "thread-id", + "windows-sys", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "petgraph" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a13a2fa9d0b63e5f22328828741e523766fff0ee9e779316902290dff3f824f" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pkg-config" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro-crate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebace6889caf889b4d3f76becee12e90353f2b8c7d875534a71e5742f8f6f83" +dependencies = [ + "thiserror", + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "raw-window-handle" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba75eee94a9d5273a68c9e1e105d9cffe1ef700532325788389e5a83e2522b7" +dependencies = [ + "cty", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "ron" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b861ecaade43ac97886a512b360d01d66be9f41f3c61088b42cedf92e03d678" +dependencies = [ + "base64", + "bitflags", + "serde", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.136" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.136" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "slotmap" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e08e261d0e8f5c43123b7adf3e4ca1690d655377ac93a03b2c9d3e98de1342" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "smithay-client-toolkit" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1325f292209cee78d5035530932422a30aa4c8fda1a16593ac083c1de211e68a" +dependencies = [ + "bitflags", + "calloop", + "dlib", + "lazy_static", + "log", + "memmap2", + "nix", + "pkg-config", + "wayland-client", + "wayland-cursor", + "wayland-protocols", +] + +[[package]] +name = "str-buf" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread-id" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fdfe0627923f7411a43ec9ec9c39c3a9b4151be313e0922042581fb6c9b717f" +dependencies = [ + "libc", + "redox_syscall", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "tracing" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" +dependencies = [ + "cfg-if 1.0.0", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "ttf-parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccbe8381883510b6a2d8f1e32905bddd178c11caef8083086d0c0c9ab0ac281" + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "1.0.0-alpha.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3ab47baa004111b323696c6eaa2752e7356f7f77cf6b6dc7a2087368ce1ca4" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasm-bindgen" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb6ec270a31b1d3c7e266b999739109abce8b6c87e4b31fcfcd788b65267395" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" + +[[package]] +name = "wayland-client" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91223460e73257f697d9e23d401279123d36039a3f7a449e983f123292d4458f" +dependencies = [ + "bitflags", + "downcast-rs", + "libc", + "nix", + "scoped-tls", + "wayland-commons", + "wayland-scanner", + "wayland-sys", +] + +[[package]] +name = "wayland-commons" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6e5e340d7c13490eca867898c4cec5af56c27a5ffe5c80c6fc4708e22d33e" +dependencies = [ + "nix", + "once_cell", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-cursor" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52758f13d5e7861fc83d942d3d99bf270c83269575e52ac29e5b73cb956a6bd" +dependencies = [ + "nix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-egl" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83281d69ee162b59031c666385e93bde4039ec553b90c4191cdb128ceea29a3a" +dependencies = [ + "wayland-client", + "wayland-sys", +] + +[[package]] +name = "wayland-protocols" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60147ae23303402e41fe034f74fb2c35ad0780ee88a1c40ac09a3be1e7465741" +dependencies = [ + "bitflags", + "wayland-client", + "wayland-commons", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a1ed3143f7a143187156a2ab52742e89dac33245ba505c17224df48939f9e0" +dependencies = [ + "proc-macro2", + "quote", + "xml-rs", +] + +[[package]] +name = "wayland-sys" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9341df79a8975679188e37dab3889bfa57c44ac2cb6da166f519a81cbe452d4" +dependencies = [ + "dlib", + "lazy_static", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a3cffdb686fbb24d9fb8f03a213803277ed2300f11026a3afe1f108dc021b" +dependencies = [ + "jni", + "ndk-glue", + "url", + "web-sys", + "widestring 0.5.1", + "winapi", +] + +[[package]] +name = "widestring" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17882f045410753661207383517a6f62ec3dbeb6a4ed2acce01f0728238d1983" + +[[package]] +name = "widestring" +version = "1.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f1efe828a707edf85994a4501734ac1c1b9d244cfcf4de235f11c4125ace8f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-wsapoll" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows-implement", + "windows-interface", + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + +[[package]] +name = "windows-implement" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a1062e555f7d9d66fd1130ed4f7c6ec41a47529ee0850cd0e926d95b26bb14" +dependencies = [ + "syn", + "windows-tokens", +] + +[[package]] +name = "windows-interface" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e426420090dc12090b97fdbd2ce080265d1430e3776e36029e0a38f0f85cd925" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-tokens" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3263d25f1170419995b78ff10c06b949e8a986c35c208dc24333c64753a87169" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + +[[package]] +name = "winit" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b43cc931d58b99461188607efd7acb2a093e65fc621f54cad78517a6063e73a" +dependencies = [ + "bitflags", + "cocoa", + "core-foundation 0.9.3", + "core-graphics 0.22.3", + "core-video-sys", + "dispatch", + "instant", + "lazy_static", + "libc", + "log", + "mio", + "ndk", + "ndk-glue", + "ndk-sys", + "objc", + "parking_lot 0.11.2", + "percent-encoding", + "raw-window-handle", + "smithay-client-toolkit", + "wasm-bindgen", + "wayland-client", + "wayland-protocols", + "web-sys", + "winapi", + "x11-dl", +] + +[[package]] +name = "x11-dl" +version = "2.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea26926b4ce81a6f5d9d0f3a0bc401e5a37c6ae14a1bfaa8ff6099ca80038c59" +dependencies = [ + "lazy_static", + "libc", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e99be55648b3ae2a52342f9a870c0e138709a3493261ce9b469afe6e4df6d8a" +dependencies = [ + "gethostname", + "nix", + "winapi", + "winapi-wsapoll", +] + +[[package]] +name = "xcursor" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463705a63313cd4301184381c5e8042f0a7e9b4bb63653f216311d4ae74690b7" +dependencies = [ + "nom", +] + +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..044f1de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "nodio" +version = "0.1.0" +edition = "2021" + +[workspace] +members = ["crates/*"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42de4c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Urho Laukkarinen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..47c18f0 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Nodio + +![Screenshot of Nodio](docs/screenshot.png) + +> **Note** +> This project is still very much a work in progress and experimental. Use with caution. + +Nodio is a node-based audio routing application that allows you to route audio from applications and input +devices to multiple output devices. + +At the moment Nodio only works on **Windows 10 (ver. 21H1 and later)** and **Windows 11**, but Mac and Linux support may come *later*™. + +## Usage +``` +cargo run -p nodio-app +``` + +## Features + +* Route audio from an application to one or several output devices. On Windows this works by switching the application's +default audio endpoint to the first connected output device, and using software loopback recording for the rest. + +* Route audio from an input device (e.g. a microphone) to one output device. On Windows this works by using Windows' +"Listen to this device" feature. + +* The nodes and connections are automatically saved. If the application is restarted, the previous layout is loaded +and applied. \ No newline at end of file diff --git a/crates/nodio-api/Cargo.toml b/crates/nodio-api/Cargo.toml new file mode 100644 index 0000000..824f643 --- /dev/null +++ b/crates/nodio-api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "nodio-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +nodio-core = { path = "../nodio-core" } +parking_lot = "0.12.0" + +[target.'cfg(windows)'.dependencies] +nodio-win32 = { path = "../nodio-win32" } \ No newline at end of file diff --git a/crates/nodio-api/src/lib.rs b/crates/nodio-api/src/lib.rs new file mode 100644 index 0000000..2e13d8c --- /dev/null +++ b/crates/nodio-api/src/lib.rs @@ -0,0 +1,11 @@ +#![deny(clippy::all)] +use nodio_core::Context; +use parking_lot::RwLock; +use std::sync::Arc; + +#[cfg(target_os = "windows")] +use nodio_win32::Win32Context as PlatformContext; + +pub fn create_nodio_context() -> Arc> { + PlatformContext::new() +} diff --git a/crates/nodio-app/Cargo.toml b/crates/nodio-app/Cargo.toml new file mode 100644 index 0000000..64b4735 --- /dev/null +++ b/crates/nodio-app/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nodio-app" +version = "0.1.0" +edition = "2021" + +[dependencies] +nodio-core = { path = "../nodio-core" } +nodio-api = { path = "../nodio-api" } +nodio-gui-nodes = { path = "../nodio-gui-nodes" } + +eframe = { version = "0.18.0", features = ["persistence"] } +egui-toast = "0.2.0" + +pretty_env_logger = "0.4.0" +log = "0.4.17" +indexmap = "1.8.1" +serde_json = "1.0.81" +parking_lot = "0.12.0" \ No newline at end of file diff --git a/crates/nodio-app/fonts/Lato-Regular.ttf b/crates/nodio-app/fonts/Lato-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..bb2e8875a993f9c7d9e45d0eeffa839550cc6287 GIT binary patch literal 75152 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 Box { + let mut app = MyApp::default(); + + let mut style = Style::default(); + style.visuals.override_text_color = Some(Color32::from_rgb(225, 225, 225)); + style.visuals.widgets.noninteractive.bg_fill = Color32::from_rgba_unmultiplied(50, 50, 50, 255); + setup_ctx.egui_ctx.set_style(style); + + let mut fonts = FontDefinitions::default(); + fonts.font_data.insert( + "custom".to_owned(), + FontData::from_static(include_bytes!("../fonts/Lato-Regular.ttf")), + ); + fonts + .families + .get_mut(&FontFamily::Proportional) + .unwrap() + .insert(0, "custom".to_owned()); + fonts + .families + .get_mut(&FontFamily::Monospace) + .unwrap() + .push("custom".to_owned()); + + setup_ctx.egui_ctx.set_fonts(fonts); + + if let Some(nodes_json) = setup_ctx + .storage + .and_then(|storage| storage.get_string("nodes")) + { + let mut ctx = app.ctx.write(); + for node in serde_json::from_str::>(&nodes_json).unwrap_or_default() { + ctx.add_node(node); + } + } + + if let Some(links_json) = setup_ctx + .storage + .and_then(|storage| storage.get_string("links")) + { + let mut ctx = app.ctx.write(); + for (id, start, end) in serde_json::from_str::>(&links_json).unwrap_or_default() { + if app.ui_links.insert(id, (start, end)).is_none() { + ctx.connect_node(start, end).ok(); + } + } + } + + Box::new(app) +} + +#[derive(Copy, Clone)] +enum ContextMenuKind { + Node(Uuid), + Editor, +} + +struct MyApp { + ctx: Arc>, + node_ctx: NodeContext, + /// Links between nodes (id, (start -> end)) + ui_links: IndexMap, + context_menu_kind: Option, + detached_link: Option<(Uuid, Uuid)>, + + should_save: bool, +} + +impl Default for MyApp { + fn default() -> Self { + Self { + ctx: create_nodio_context(), + node_ctx: NodeContext::default(), + ui_links: IndexMap::new(), + context_menu_kind: None, + detached_link: None, + should_save: false, + } + } +} + +impl MyApp { + fn interact_and_draw(&mut self, ui_ctx: &egui::Context, ui: &mut Ui) { + let node_count = self.ctx.read().nodes().len(); + + let mut toasts = Toasts::new(ui_ctx) + .anchor( + ui_ctx + .available_rect() + .max + .sub(Pos2::new(10.0, 10.0)) + .to_pos2(), + ) + .align_to_end(true) + .direction(Direction::BottomUp); + + self.node_ctx.begin_frame(ui); + + for node_idx in 0..node_count { + let Node { + id: node_id, + kind: node_kind, + volume: mut node_volume, + active: node_active, + present: node_present, + peak_values: node_peak_values, + display_name: node_display_name, + pos: node_pos, + .. + } = self.ctx.read().nodes().get(node_idx).cloned().unwrap(); + + let pin_args = match node_kind { + NodeKind::Application | NodeKind::InputDevice => PinArgs::default(), + NodeKind::OutputDevice => PinArgs { + flags: Some(AttributeFlags::EnableLinkDetachWithDragClick as _), + ..Default::default() + }, + }; + + let header_contents = |ui: &mut Ui| { + ui.vertical_centered(|ui| { + ui.add_enabled_ui(node_present, move |ui| { + ui.label(format!( + "{}{}", + node_display_name, + if node_active { " 🔉" } else { "" } + )) + }); + }); + }; + + let attr_contents = { + let ctx = self.ctx.clone(); + move |ui: &mut Ui| { + ui.vertical(|ui| { + ui.add_enabled_ui(node_present, |ui| { + ui.spacing_mut().slider_width = 130.0; + + if VolumeSlider::new(&mut node_volume, node_peak_values) + .ui(ui) + .changed() + { + ctx.write().set_volume(node_id, node_volume); + } + }); + }) + .response + } + }; + + let mut node = self + .node_ctx + .add_node(node_id) + .with_origin(pos2(node_pos.0, node_pos.1)) + .with_header(header_contents); + + match node_kind { + NodeKind::Application | NodeKind::InputDevice => { + node.with_output_attribute(node_id, pin_args, attr_contents); + } + NodeKind::OutputDevice => { + node.with_input_attribute(node_id, pin_args, attr_contents); + } + } + + node.show(ui); + } + + for (&id, &(start, end)) in self.ui_links.iter() { + self.node_ctx + .add_link(id, start, end, LinkArgs::default(), ui); + } + + let nodes_response = self.node_ctx.end_frame(ui); + + self.context_menu(nodes_response); + + if let Some(id) = self.node_ctx.detached_link() { + debug!("link detached: {}", id); + + if let Some((from, to)) = self.ui_links.remove(&id) { + self.ctx.write().disconnect_node(from, to); + self.detached_link = Some((from, to)); + } + } + + if let Some(id) = self.node_ctx.dropped_link() { + debug!("link dropped: {}", id); + + self.should_save = true; + self.detached_link = None; + } + + if let Some((start, end, from_snap)) = self.node_ctx.created_link() { + debug!("link created: {}, ({} to {})", start, end, from_snap); + + match self.ctx.write().connect_node(start, end) { + Ok(()) => { + self.ui_links.retain(|_, link| *link != (start, end)); + self.ui_links.insert(Uuid::new_v4(), (start, end)); + } + Err(err) => { + warn!("Failed to connect nodes: {}", err); + + toasts.error(err.to_string(), Duration::from_secs(10)); + + if let Some((from, to)) = self.detached_link.take() { + self.ui_links.insert(Uuid::new_v4(), (from, to)); + } + } + } + + self.should_save = true; + } + + if node_count == 0 { + ui.centered_and_justified(|ui| { + ui.label( + RichText::new("Right-click anywhere to add nodes") + .heading() + .color(ui.visuals().widgets.inactive.text_color()), + ); + }); + } + + if ui.input().key_pressed(egui::Key::Delete) { + self.remove_selected_nodes(); + } + + toasts.show(); + } + + fn context_menu(&mut self, nodes_response: Response) { + let context_menu_kind = self + .context_menu_kind + .take() + .or_else(|| self.node_ctx.hovered_node().map(ContextMenuKind::Node)) + .unwrap_or(ContextMenuKind::Editor); + + nodes_response.context_menu(|ui| { + self.context_menu_kind = Some(context_menu_kind); + + match context_menu_kind { + ContextMenuKind::Node(node_id) => self.node_context_menu_items(ui, node_id), + ContextMenuKind::Editor => self.editor_context_menu_items(ui), + } + }); + } + + fn node_context_menu_items(&mut self, ui: &mut Ui, node_id: Uuid) { + if ui.button("Remove").clicked() { + self.ctx.write().remove_node(node_id); + self.ui_links + .retain(|_, (start, end)| *start != node_id && *end != node_id); + + // Remove other nodes too, when multiple nodes selected + self.remove_selected_nodes(); + + ui.close_menu(); + } + } + + fn remove_selected_nodes(&mut self) { + for &node_id in self.node_ctx.get_selected_nodes() { + self.ctx.write().remove_node(node_id); + self.ui_links + .retain(|_, (start, end)| *start != node_id && *end != node_id); + } + } + + fn editor_context_menu_items(&mut self, ui: &mut Ui) { + let mut added_node = None; + + let menu_pos = ui + .add_enabled_ui(false, |ui| ui.label("Add node")) + .response + .rect + .min; + + ui.menu_button("Application", |ui| { + for process in self.ctx.read().application_processes() { + Self::application_node_button(&mut added_node, menu_pos, ui, process); + } + }); + + ui.menu_button("Input device", |ui| { + for device in self.ctx.read().input_devices() { + Self::device_node_button( + &mut added_node, + menu_pos, + ui, + device, + NodeKind::InputDevice, + ); + } + }); + + ui.menu_button("Output device", |ui| { + for device in self.ctx.read().output_devices() { + Self::device_node_button( + &mut added_node, + menu_pos, + ui, + device, + NodeKind::OutputDevice, + ); + } + }); + + if let Some(node) = added_node { + self.ctx.write().add_node(node); + self.should_save = true; + } + } + + fn application_node_button( + added_node: &mut Option, + menu_pos: Pos2, + ui: &mut Ui, + process: ProcessInfo, + ) { + if egui::Button::new(&process.display_name) + .wrap(false) + .ui(ui) + .clicked() + { + added_node.replace(Node { + kind: NodeKind::Application, + display_name: process.display_name, + filename: process.filename, + pos: (menu_pos.x, menu_pos.y), + process_id: Some(process.pid), + ..Default::default() + }); + ui.close_menu(); + } + } + + fn device_node_button( + added_node: &mut Option, + menu_pos: Pos2, + ui: &mut Ui, + device: DeviceInfo, + node_kind: NodeKind, + ) { + if egui::Button::new(&device.name).wrap(false).ui(ui).clicked() { + added_node.replace(Node { + id: device.id, + kind: node_kind, + display_name: device.name, + pos: (menu_pos.x, menu_pos.y), + ..Default::default() + }); + ui.close_menu(); + } + } +} + +impl App for MyApp { + fn update(&mut self, ui_ctx: &egui::Context, _frame: &mut Frame) { + egui::CentralPanel::default() + .frame(egui::Frame::none()) + .show(ui_ctx, |ui| self.interact_and_draw(ui_ctx, ui)); + + ui_ctx.request_repaint(); + } + + fn save(&mut self, storage: &mut dyn Storage) { + debug!("Saving state"); + + self.should_save = false; + + let mut nodes = self.ctx.read().nodes().to_vec(); + for node in nodes.iter_mut() { + if let Some(pos) = self.node_ctx.node_pos(node.id) { + node.pos = (pos.x, pos.y); + } + } + + let links: Vec<(Uuid, Uuid, Uuid)> = self + .ui_links + .iter() + .map(|(id, (start, end))| (*id, *start, *end)) + .collect::<_>(); + + storage.set_string("nodes", serde_json::to_string_pretty(&nodes).unwrap()); + storage.set_string("links", serde_json::to_string_pretty(&links).unwrap()); + } + + fn auto_save_interval(&self) -> Duration { + if self.should_save { + Duration::from_secs(0) + } else { + Duration::from_secs(30) + } + } +} diff --git a/crates/nodio-app/src/slider.rs b/crates/nodio-app/src/slider.rs new file mode 100644 index 0000000..a8a8afa --- /dev/null +++ b/crates/nodio-app/src/slider.rs @@ -0,0 +1,688 @@ +#![allow(dead_code)] +///! Modified version of [egui::widgets::Slider] for audio channel visualization +use eframe::egui; +use egui::*; +use std::ops::RangeInclusive; + +#[derive(Clone)] +struct SliderSpec { + logarithmic: bool, + /// For logarithmic sliders, the smallest positive value we are interested in. + /// 1 for integer sliders, maybe 1e-6 for others. + smallest_positive: f64, + /// For logarithmic sliders, the largest positive value we are interested in + /// before the slider switches to `INFINITY`, if that is the higher end. + /// Default: INFINITY. + largest_finite: f64, +} + +/// Combined into one function (rather than two) to make it easier +/// for the borrow checker. +type GetSetValue<'a> = Box) -> f64>; + +fn get(get_set_value: &mut GetSetValue<'_>) -> f64 { + (get_set_value)(None) +} + +fn set(get_set_value: &mut GetSetValue<'_>, value: f64) { + (get_set_value)(Some(value)); +} + +/// Specifies the orientation of a [`VolumeSlider`]. +pub enum SliderOrientation { + Horizontal, + Vertical, +} + +#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] +pub struct VolumeSlider<'a> { + get_set_value: GetSetValue<'a>, + range: RangeInclusive, + spec: SliderSpec, + clamp_to_range: bool, + smart_aim: bool, + show_value: bool, + orientation: SliderOrientation, + prefix: String, + suffix: String, + text: String, + text_color: Option, + min_decimals: usize, + max_decimals: Option, + peak_values: (f32, f32), +} + +impl<'a> VolumeSlider<'a> { + pub fn new(value: &'a mut f32, peak_values: (f32, f32)) -> Self { + let range = 0.0..=100.0; + let slf = Self::from_get_set( + range, + move |v: Option| { + if let Some(v) = v { + *value = v as f32 / 100.0; + } + *value as f64 * 100.0 + }, + peak_values, + ); + + slf.integer() + } + + pub fn from_get_set( + range: RangeInclusive, + get_set_value: impl 'a + FnMut(Option) -> f64, + peak_values: (f32, f32), + ) -> Self { + Self { + get_set_value: Box::new(get_set_value), + range, + spec: SliderSpec { + logarithmic: false, + smallest_positive: 1e-6, + largest_finite: f64::INFINITY, + }, + clamp_to_range: true, + smart_aim: true, + show_value: true, + orientation: SliderOrientation::Horizontal, + prefix: Default::default(), + suffix: Default::default(), + text: Default::default(), + text_color: None, + min_decimals: 0, + max_decimals: None, + peak_values, + } + } + + /// Control whether or not the slider shows the current value. + /// Default: `true`. + pub fn show_value(mut self, show_value: bool) -> Self { + self.show_value = show_value; + self + } + + /// Show a prefix before the number, e.g. "x: " + pub fn prefix(mut self, prefix: impl ToString) -> Self { + self.prefix = prefix.to_string(); + self + } + + /// Add a suffix to the number, this can be e.g. a unit ("°" or " m") + pub fn suffix(mut self, suffix: impl ToString) -> Self { + self.suffix = suffix.to_string(); + self + } + + /// Show a text next to the slider (e.g. explaining what the slider controls). + pub fn text(mut self, text: impl ToString) -> Self { + self.text = text.to_string(); + self + } + + pub fn text_color(mut self, text_color: Color32) -> Self { + self.text_color = Some(text_color); + self + } + + /// Vertical or horizontal slider? The default is horizontal. + pub fn orientation(mut self, orientation: SliderOrientation) -> Self { + self.orientation = orientation; + self + } + + /// Make this a vertical slider. + pub fn vertical(mut self) -> Self { + self.orientation = SliderOrientation::Vertical; + self + } + + /// Make this a logarithmic slider. + /// This is great for when the slider spans a huge range, + /// e.g. from one to a million. + /// The default is OFF. + pub fn logarithmic(mut self, logarithmic: bool) -> Self { + self.spec.logarithmic = logarithmic; + self + } + + /// For logarithmic sliders that includes zero: + /// what is the smallest positive value you want to be able to select? + /// The default is `1` for integer sliders and `1e-6` for real sliders. + pub fn smallest_positive(mut self, smallest_positive: f64) -> Self { + self.spec.smallest_positive = smallest_positive; + self + } + + /// For logarithmic sliders, the largest positive value we are interested in + /// before the slider switches to `INFINITY`, if that is the higher end. + /// Default: INFINITY. + pub fn largest_finite(mut self, largest_finite: f64) -> Self { + self.spec.largest_finite = largest_finite; + self + } + + /// If set to `true`, all incoming and outgoing values will be clamped to the slider range. + /// Default: `true`. + pub fn clamp_to_range(mut self, clamp_to_range: bool) -> Self { + self.clamp_to_range = clamp_to_range; + self + } + + /// Turn smart aim on/off. Default is ON. + /// There is almost no point in turning this off. + pub fn smart_aim(mut self, smart_aim: bool) -> Self { + self.smart_aim = smart_aim; + self + } + + /// Set a minimum number of decimals to display. + /// Normally you don't need to pick a precision, as the slider will intelligently pick a precision for you. + /// Regardless of precision the slider will use "smart aim" to help the user select nice, round values. + pub fn min_decimals(mut self, min_decimals: usize) -> Self { + self.min_decimals = min_decimals; + self + } + + /// Set a maximum number of decimals to display. + /// Values will also be rounded to this number of decimals. + /// Normally you don't need to pick a precision, as the slider will intelligently pick a precision for you. + /// Regardless of precision the slider will use "smart aim" to help the user select nice, round values. + pub fn max_decimals(mut self, max_decimals: usize) -> Self { + self.max_decimals = Some(max_decimals); + self + } + + /// Set an exact number of decimals to display. + /// Values will also be rounded to this number of decimals. + /// Normally you don't need to pick a precision, as the slider will intelligently pick a precision for you. + /// Regardless of precision the slider will use "smart aim" to help the user select nice, round values. + pub fn fixed_decimals(mut self, num_decimals: usize) -> Self { + self.min_decimals = num_decimals; + self.max_decimals = Some(num_decimals); + self + } + + /// Helper: equivalent to `self.precision(0).smallest_positive(1.0)`. + /// If you use one of the integer constructors (e.g. `Slider::i32`) this is called for you, + /// but if you want to have a slider for picking integer values in an `Slider::f64`, use this. + pub fn integer(self) -> Self { + self.fixed_decimals(0).smallest_positive(1.0) + } + + fn get_value(&mut self) -> f64 { + let value = get(&mut self.get_set_value); + if self.clamp_to_range { + let start = *self.range.start(); + let end = *self.range.end(); + value.clamp(start.min(end), start.max(end)) + } else { + value + } + } + + fn set_value(&mut self, mut value: f64) { + if self.clamp_to_range { + let start = *self.range.start(); + let end = *self.range.end(); + value = value.clamp(start.min(end), start.max(end)); + } + if let Some(max_decimals) = self.max_decimals { + value = emath::round_to_decimals(value, max_decimals); + } + set(&mut self.get_set_value, value); + } + + fn clamp_range(&self) -> RangeInclusive { + if self.clamp_to_range { + self.range() + } else { + f64::NEG_INFINITY..=f64::INFINITY + } + } + + fn range(&self) -> RangeInclusive { + self.range.clone() + } + + /// For instance, `position` is the mouse position and `position_range` is the physical location of the slider on the screen. + fn value_from_position(&self, position: f32, position_range: RangeInclusive) -> f64 { + let normalized = remap_clamp(position, position_range, 0.0..=1.0) as f64; + value_from_normalized(normalized, self.range(), &self.spec) + } + + fn position_from_value(&self, value: f64, position_range: RangeInclusive) -> f32 { + let normalized = normalized_from_value(value, self.range(), &self.spec); + lerp(position_range, normalized as f32) + } +} + +impl<'a> VolumeSlider<'a> { + /// Just the slider, no text + fn allocate_slider_space(&self, ui: &mut Ui, perpendicular: f32) -> Response { + let desired_size = match self.orientation { + SliderOrientation::Horizontal => vec2(ui.spacing().slider_width, perpendicular), + SliderOrientation::Vertical => vec2(perpendicular, ui.spacing().slider_width), + }; + ui.allocate_response(desired_size, Sense::click_and_drag()) + } + + /// Just the slider, no text + fn slider_ui(&mut self, ui: &mut Ui, response: &Response) { + let rect = &response.rect; + let position_range = self.position_range(rect); + + if let Some(pointer_position_2d) = response.interact_pointer_pos() { + let position = self.pointer_position(pointer_position_2d); + let new_value = if self.smart_aim { + let aim_radius = ui.input().aim_radius(); + emath::smart_aim::best_in_range_f64( + self.value_from_position(position - aim_radius, position_range.clone()), + self.value_from_position(position + aim_radius, position_range.clone()), + ) + } else { + self.value_from_position(position, position_range.clone()) + }; + self.set_value(new_value); + } + + let value = self.get_value(); + response.widget_info(|| WidgetInfo::slider(value, &self.text)); + + if response.has_focus() { + let (dec_key, inc_key) = match self.orientation { + SliderOrientation::Horizontal => (Key::ArrowLeft, Key::ArrowRight), + // Note that this is for moving the slider position, + // so up = decrement y coordinate: + SliderOrientation::Vertical => (Key::ArrowUp, Key::ArrowDown), + }; + + let decrement = ui.input().num_presses(dec_key); + let increment = ui.input().num_presses(inc_key); + let kb_step = increment as f32 - decrement as f32; + + if kb_step != 0.0 { + let prev_value = self.get_value(); + let prev_position = self.position_from_value(prev_value, position_range.clone()); + let new_position = prev_position + kb_step; + let new_value = if self.smart_aim { + let aim_radius = ui.input().aim_radius(); + emath::smart_aim::best_in_range_f64( + self.value_from_position(new_position - aim_radius, position_range.clone()), + self.value_from_position(new_position + aim_radius, position_range.clone()), + ) + } else { + self.value_from_position(new_position, position_range.clone()) + }; + self.set_value(new_value); + } + } + + // Paint it: + if ui.is_rect_visible(response.rect) { + let value = self.get_value(); + + let rail_radius = ui.painter().round_to_pixel(self.rail_radius_limit(rect)); + let rail_rect = self.rail_rect(rect, rail_radius); + + let position_1d = self.position_from_value(value, position_range.clone()); + let position_peak_left = + self.position_from_value(self.peak_values.0 as f64 * value, position_range.clone()); + let position_peak_right = + self.position_from_value(self.peak_values.1 as f64 * value, position_range); + + let mut peak_left_rect = self.rail_rect(rect, rail_radius); + let mut peak_right_rect = self.rail_rect(rect, rail_radius); + + match self.orientation { + SliderOrientation::Horizontal => { + peak_left_rect.max.x = position_peak_left; + peak_right_rect.max.x = position_peak_right; + peak_left_rect.max.y -= rail_radius; + peak_right_rect.min.y += rail_radius; + } + SliderOrientation::Vertical => { + peak_left_rect.max.y = position_peak_left; + peak_right_rect.max.y = position_peak_right; + peak_left_rect.max.x -= rail_radius; + peak_right_rect.min.x += rail_radius; + } + } + + let visuals = ui.style().interact(response); + ui.painter().add(epaint::RectShape { + rect: rail_rect, + rounding: ui.visuals().widgets.inactive.rounding, + fill: ui.visuals().widgets.inactive.bg_fill, + // fill: visuals.bg_fill, + // fill: ui.visuals().extreme_bg_color, + stroke: Default::default(), + // stroke: visuals.bg_stroke, + // stroke: ui.visuals().widgets.inactive.bg_stroke, + }); + + if self.peak_values.0 > 1e-6 { + ui.painter().add(epaint::RectShape { + rect: peak_left_rect, + rounding: Rounding::same(0.0), + fill: visuals.text_color(), + stroke: Default::default(), + }); + } + + if self.peak_values.1 > 1e-6 { + ui.painter().add(epaint::RectShape { + rect: peak_right_rect, + rounding: Rounding::same(0.0), + fill: visuals.text_color(), + stroke: Default::default(), + }); + } + + let center = self.marker_center(position_1d, &rail_rect); + + ui.painter().add(epaint::RectShape { + rect: self.handle_rect(center, rect), + fill: visuals.bg_fill, + stroke: visuals.fg_stroke, + rounding: Rounding::same(2.0), + }); + } + } + + fn marker_center(&self, position_1d: f32, rail_rect: &Rect) -> Pos2 { + match self.orientation { + SliderOrientation::Horizontal => pos2(position_1d, rail_rect.center().y), + SliderOrientation::Vertical => pos2(rail_rect.center().x, position_1d), + } + } + + fn pointer_position(&self, pointer_position_2d: Pos2) -> f32 { + match self.orientation { + SliderOrientation::Horizontal => pointer_position_2d.x, + SliderOrientation::Vertical => pointer_position_2d.y, + } + } + + fn position_range(&self, rect: &Rect) -> RangeInclusive { + let handle_radius = 0.0; //self.handle_radius(rect); + match self.orientation { + SliderOrientation::Horizontal => { + (rect.left() + handle_radius)..=(rect.right() - handle_radius) + } + SliderOrientation::Vertical => { + (rect.bottom() - handle_radius)..=(rect.top() + handle_radius) + } + } + } + + fn rail_rect(&self, rect: &Rect, radius: f32) -> Rect { + match self.orientation { + SliderOrientation::Horizontal => Rect::from_min_max( + pos2(rect.left(), rect.center().y - radius), + pos2(rect.right(), rect.center().y + radius), + ), + SliderOrientation::Vertical => Rect::from_min_max( + pos2(rect.center().x - radius, rect.top()), + pos2(rect.center().x + radius, rect.bottom()), + ), + } + } + + fn handle_rect(&self, center: Pos2, rect: &Rect) -> Rect { + let size = match self.orientation { + SliderOrientation::Horizontal => Vec2::new(8.0, rect.height() / 1.25), + SliderOrientation::Vertical => Vec2::new(rect.width() / 1.25, 8.0), + }; + + Rect::from_center_size(center, size) + } + + fn rail_radius_limit(&self, rect: &Rect) -> f32 { + match self.orientation { + SliderOrientation::Horizontal => (rect.height() / 4.0).at_least(2.0), + SliderOrientation::Vertical => (rect.width() / 4.0).at_least(2.0), + } + } + + fn label_ui(&mut self, ui: &mut Ui) { + if !self.text.is_empty() { + let text_color = self.text_color.unwrap_or_else(|| ui.visuals().text_color()); + let text = RichText::new(&self.text).color(text_color); + ui.add(Label::new(text).wrap(false)); + } + } + + fn value_ui(&mut self, ui: &mut Ui, position_range: RangeInclusive) -> Response { + let speed = self.current_gradient(&position_range); + + let mut value = self.get_value(); + let response = ui.add( + DragValue::new(&mut value) + .speed(speed) + .clamp_range(self.clamp_range()) + .min_decimals(self.min_decimals) + .max_decimals_opt(self.max_decimals) + .suffix(self.suffix.clone()) + .prefix(self.prefix.clone()), + ); + if value != self.get_value() { + self.set_value(value); + } + response + } + + fn current_gradient(&mut self, position_range: &RangeInclusive) -> f64 { + let value = self.get_value(); + let value_from_pos = + |position: f32| self.value_from_position(position, position_range.clone()); + let pos_from_value = |value: f64| self.position_from_value(value, position_range.clone()); + let left_value = value_from_pos(pos_from_value(value) - 0.5); + let right_value = value_from_pos(pos_from_value(value) + 0.5); + right_value - left_value + } + + fn add_contents(&mut self, ui: &mut Ui) -> Response { + let thickness = ui + .text_style_height(&TextStyle::Body) + .at_least(ui.spacing().interact_size.y); + let mut response = self.allocate_slider_space(ui, thickness); + self.slider_ui(ui, &response); + + if self.show_value { + let position_range = self.position_range(&response.rect); + let value_response = self.value_ui(ui, position_range); + if value_response.gained_focus() + || value_response.has_focus() + || value_response.lost_focus() + { + // Use the [`DragValue`] id as the id of the whole widget, + // so that the focus events work as expected. + response = value_response.union(response); + } else { + // Use the slider id as the id for the whole widget + response = response.union(value_response); + } + } + + if !self.text.is_empty() { + let text_color = self.text_color.unwrap_or_else(|| ui.visuals().text_color()); + let text = RichText::new(&self.text).color(text_color); + ui.add(Label::new(text).wrap(false)); + } + + response + } +} + +impl<'a> Widget for VolumeSlider<'a> { + fn ui(mut self, ui: &mut Ui) -> Response { + let old_value = self.get_value(); + + let inner_response = match self.orientation { + SliderOrientation::Horizontal => ui.horizontal(|ui| self.add_contents(ui)), + SliderOrientation::Vertical => ui.vertical(|ui| self.add_contents(ui)), + }; + + let mut response = inner_response.inner | inner_response.response; + if self.get_value() != old_value { + response.mark_changed(); + } + response + } +} + +// ---------------------------------------------------------------------------- +// Helpers for converting slider range to/from normalized [0-1] range. +// Always clamps. +// Logarithmic sliders are allowed to include zero and infinity, +// even though mathematically it doesn't make sense. + +use std::f64::INFINITY; + +/// When the user asks for an infinitely large range (e.g. logarithmic from zero), +/// give a scale that this many orders of magnitude in size. +const INF_RANGE_MAGNITUDE: f64 = 10.0; + +fn value_from_normalized(normalized: f64, range: RangeInclusive, spec: &SliderSpec) -> f64 { + let (min, max) = (*range.start(), *range.end()); + + if min.is_nan() || max.is_nan() { + f64::NAN + } else if min == max { + min + } else if min > max { + value_from_normalized(1.0 - normalized, max..=min, spec) + } else if normalized <= 0.0 { + min + } else if normalized >= 1.0 { + max + } else if spec.logarithmic { + if max <= 0.0 { + // non-positive range + -value_from_normalized(normalized, -min..=-max, spec) + } else if 0.0 <= min { + let (min_log, max_log) = range_log10(min, max, spec); + let log = lerp(min_log..=max_log, normalized); + 10.0_f64.powf(log) + } else { + assert!(min < 0.0 && 0.0 < max); + let zero_cutoff = logaritmic_zero_cutoff(min, max); + if normalized < zero_cutoff { + // negative + value_from_normalized( + remap(normalized, 0.0..=zero_cutoff, 0.0..=1.0), + min..=0.0, + spec, + ) + } else { + // positive + value_from_normalized( + remap(normalized, zero_cutoff..=1.0, 0.0..=1.0), + 0.0..=max, + spec, + ) + } + } + } else { + egui::egui_assert!( + min.is_finite() && max.is_finite(), + "You should use a logarithmic range" + ); + lerp(range, normalized.clamp(0.0, 1.0)) + } +} + +fn normalized_from_value(value: f64, range: RangeInclusive, spec: &SliderSpec) -> f64 { + let (min, max) = (*range.start(), *range.end()); + + if min.is_nan() || max.is_nan() { + f64::NAN + } else if min == max { + 0.5 // empty range, show center of slider + } else if min > max { + 1.0 - normalized_from_value(value, max..=min, spec) + } else if value <= min { + 0.0 + } else if value >= max { + 1.0 + } else if spec.logarithmic { + if max <= 0.0 { + // non-positive range + normalized_from_value(-value, -min..=-max, spec) + } else if 0.0 <= min { + let (min_log, max_log) = range_log10(min, max, spec); + let value_log = value.log10(); + remap_clamp(value_log, min_log..=max_log, 0.0..=1.0) + } else { + assert!(min < 0.0 && 0.0 < max); + let zero_cutoff = logaritmic_zero_cutoff(min, max); + if value < 0.0 { + // negative + remap( + normalized_from_value(value, min..=0.0, spec), + 0.0..=1.0, + 0.0..=zero_cutoff, + ) + } else { + // positive side + remap( + normalized_from_value(value, 0.0..=max, spec), + 0.0..=1.0, + zero_cutoff..=1.0, + ) + } + } + } else { + egui::egui_assert!( + min.is_finite() && max.is_finite(), + "You should use a logarithmic range" + ); + remap_clamp(value, range, 0.0..=1.0) + } +} + +fn range_log10(min: f64, max: f64, spec: &SliderSpec) -> (f64, f64) { + assert!(spec.logarithmic); + assert!(min <= max); + + if min == 0.0 && max == INFINITY { + (spec.smallest_positive.log10(), INF_RANGE_MAGNITUDE) + } else if min == 0.0 { + if spec.smallest_positive < max { + (spec.smallest_positive.log10(), max.log10()) + } else { + (max.log10() - INF_RANGE_MAGNITUDE, max.log10()) + } + } else if max == INFINITY { + if min < spec.largest_finite { + (min.log10(), spec.largest_finite.log10()) + } else { + (min.log10(), min.log10() + INF_RANGE_MAGNITUDE) + } + } else { + (min.log10(), max.log10()) + } +} + +/// where to put the zero cutoff for logarithmic sliders +/// that crosses zero ? +fn logaritmic_zero_cutoff(min: f64, max: f64) -> f64 { + assert!(min < 0.0 && 0.0 < max); + + let min_magnitude = if min == -INFINITY { + INF_RANGE_MAGNITUDE + } else { + min.abs().log10().abs() + }; + let max_magnitude = if max == INFINITY { + INF_RANGE_MAGNITUDE + } else { + max.log10().abs() + }; + + let cutoff = min_magnitude / (min_magnitude + max_magnitude); + egui_assert!((0.0..=1.0).contains(&cutoff)); + cutoff +} diff --git a/crates/nodio-core/Cargo.toml b/crates/nodio-core/Cargo.toml new file mode 100644 index 0000000..04a7c8b --- /dev/null +++ b/crates/nodio-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nodio-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dependencies.uuid] +version = "1.0.0-alpha.1" +features = ["v4", "serde"] \ No newline at end of file diff --git a/crates/nodio-core/src/lib.rs b/crates/nodio-core/src/lib.rs new file mode 100644 index 0000000..412f1a5 --- /dev/null +++ b/crates/nodio-core/src/lib.rs @@ -0,0 +1,78 @@ +#![deny(clippy::all)] +mod result; +pub use result::{Error, Result}; + +use serde::{Deserialize, Serialize}; +pub use uuid::Uuid; + +pub trait Context { + fn add_node(&mut self, node: Node); + fn remove_node(&mut self, node_id: Uuid); + fn nodes(&self) -> &[Node]; + fn nodes_mut(&mut self) -> &mut [Node]; + fn connect_node(&mut self, node_id: Uuid, target_id: Uuid) -> Result<()>; + fn disconnect_node(&mut self, node_id: Uuid, target_id: Uuid); + fn set_volume(&mut self, node_id: Uuid, volume: f32); + fn application_processes(&self) -> Vec; + fn input_devices(&self) -> Vec; + fn output_devices(&self) -> Vec; +} + +#[derive(Debug, Clone, PartialOrd, PartialEq, Serialize, Deserialize)] +pub struct Node { + pub id: Uuid, + pub kind: NodeKind, + pub display_name: String, + pub filename: String, + + pub pos: (f32, f32), + + #[serde(skip)] + pub process_id: Option, + #[serde(skip)] + pub active: bool, + #[serde(skip)] + pub present: bool, + #[serde(skip)] + pub volume: f32, + #[serde(skip)] + pub muted: bool, + #[serde(skip)] + pub peak_values: (f32, f32), +} + +impl Default for Node { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + kind: NodeKind::Application, + display_name: String::new(), + filename: String::new(), + pos: (0.0, 0.0), + process_id: None, + active: false, + present: false, + volume: 1.0, + muted: false, + peak_values: (0.0, 0.0), + } + } +} + +#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +pub enum NodeKind { + Application, + OutputDevice, + InputDevice, +} + +pub struct DeviceInfo { + pub id: Uuid, + pub name: String, +} + +pub struct ProcessInfo { + pub pid: u32, + pub display_name: String, + pub filename: String, +} diff --git a/crates/nodio-core/src/result.rs b/crates/nodio-core/src/result.rs new file mode 100644 index 0000000..8c0ada0 --- /dev/null +++ b/crates/nodio-core/src/result.rs @@ -0,0 +1,20 @@ +use std::fmt::{Display, Formatter}; + +pub type Result = std::result::Result; + +#[derive(Clone)] +pub enum Error { + NoSuchDevice, + CouldNotConnect(String), + Other(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::NoSuchDevice => write!(f, "No such device"), + Error::CouldNotConnect(reason) => write!(f, "Could not connect: {}", reason), + Error::Other(reason) => write!(f, "{}", reason), + } + } +} diff --git a/crates/nodio-gui-nodes/Cargo.toml b/crates/nodio-gui-nodes/Cargo.toml new file mode 100644 index 0000000..6ce8dc1 --- /dev/null +++ b/crates/nodio-gui-nodes/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nodio-gui-nodes" +version = "0.1.0" +edition = "2021" + +[dependencies] +egui = "0.18.1" +derivative = "2.2.0" +log = "0.4.17" +indexmap = "1.8.1" + +[dependencies.uuid] +version = "1.0.0-alpha.1" +features = ["v4"] \ No newline at end of file diff --git a/crates/nodio-gui-nodes/src/lib.rs b/crates/nodio-gui-nodes/src/lib.rs new file mode 100644 index 0000000..bba90e0 --- /dev/null +++ b/crates/nodio-gui-nodes/src/lib.rs @@ -0,0 +1,1397 @@ +#![deny(clippy::all)] +///! +///! Heavily modified clone of [egui_nodes](https://github.com/haighcam/egui_nodes) +///! +use std::cmp::Ordering; +use std::collections::HashMap; + +use derivative::Derivative; +use egui::{pos2, Pos2, Rect, Sense, Ui, Vec2}; +use indexmap::IndexMap; +use log::debug; +use uuid::Uuid; + +use link::*; +use node::*; +use pin::*; + +pub use { + link::LinkArgs, + node::NodeBuilder, + pin::{AttributeFlags, PinArgs, PinShape}, + style::{ColorStyle, Style, StyleFlags, StyleVar}, +}; + +mod link; +mod node; +mod pin; +mod style; + +/// The Context that tracks the state of the node editor +#[derive(Derivative)] +#[derivative(Default, Debug)] +pub struct Context { + #[derivative(Debug = "ignore")] + io: IO, + #[derivative(Debug = "ignore")] + style: Style, + + node_ids_overlapping_with_mouse: Vec, + occluded_pin_ids: Vec, + + canvas_origin_screen_space: Vec2, + #[derivative(Default(value = "[[0.0; 2].into(); 2].into()"))] + canvas_rect_screen_space: Rect, + + hovered_node_id: Option, + interactive_node_id: Option, + hovered_link_id: Option, + hovered_pin_id: Option, + detached_link_id: Option, + dropped_link_id: Option, + snap_link_id: Option, + + hovered_pin_flags: usize, + ui_element_hovered: bool, + + element_state_change: usize, + + active_attribute_id: Uuid, + active_attribute: bool, + + mouse_pos: Pos2, + mouse_delta: Vec2, + + left_mouse_pressed: bool, + left_mouse_released: bool, + alt_mouse_clicked: bool, + left_mouse_dragging: bool, + alt_mouse_dragging: bool, + mouse_in_canvas: bool, + link_detach_with_modifier_click: bool, + + nodes: IndexMap, + pins: IndexMap, + links: IndexMap, + + end_pin_link_mapping: HashMap>, + + panning: Vec2, + + selected_node_ids: Vec, + selected_link_ids: Vec, + + node_depth_order: Vec, + + partial_link: Option<(Uuid, Option)>, + + #[derivative(Default(value = "ClickInteractionType::None"))] + click_interaction_type: ClickInteractionType, + click_interaction_state: ClickInteractionState, +} + +impl Context { + pub fn begin_frame(&mut self, ui: &mut Ui) { + self.hovered_node_id.take(); + self.interactive_node_id.take(); + self.hovered_link_id.take(); + self.hovered_pin_flags = AttributeFlags::None as usize; + self.detached_link_id.take(); + self.dropped_link_id.take(); + self.snap_link_id.take(); + self.partial_link.take(); + self.end_pin_link_mapping.clear(); + self.node_ids_overlapping_with_mouse.clear(); + self.element_state_change = ElementStateChange::None as usize; + self.active_attribute = false; + self.canvas_rect_screen_space = ui.available_rect_before_wrap(); + self.canvas_origin_screen_space = self.canvas_rect_screen_space.min.to_vec2(); + + for node in self.nodes.values_mut() { + node.in_use = false; + } + for pin in self.pins.values_mut() { + pin.in_use = false; + } + for link in self.links.values_mut() { + link.in_use = false; + } + + ui.set_min_size(self.canvas_rect_screen_space.size()); + + let mut ui = ui.child_ui( + self.canvas_rect_screen_space, + egui::Layout::top_down(egui::Align::Center), + ); + + let screen_rect = ui.ctx().input().screen_rect(); + ui.set_clip_rect(self.canvas_rect_screen_space.intersect(screen_rect)); + + ui.painter().rect_filled( + self.canvas_rect_screen_space, + 0.0, + self.style.colors[ColorStyle::GridBackground as usize], + ); + + if (self.style.flags & StyleFlags::GridLines as usize) != 0 { + self.draw_grid(self.canvas_rect_screen_space.size(), &mut ui); + } + } + + pub fn end_frame(&mut self, ui: &mut Ui) -> egui::Response { + let response = ui.interact( + self.canvas_rect_screen_space, + ui.id().with("Input"), + Sense::click_and_drag(), + ); + + let mouse_pos = if let Some(mouse_pos) = response.hover_pos() { + self.mouse_in_canvas = true; + mouse_pos + } else { + self.mouse_in_canvas = false; + self.mouse_pos + }; + self.mouse_delta = mouse_pos - self.mouse_pos; + self.mouse_pos = mouse_pos; + + let left_mouse_pressed = ui + .ctx() + .input() + .pointer + .button_down(egui::PointerButton::Primary); + self.left_mouse_released = + (self.left_mouse_pressed || self.left_mouse_dragging) && !left_mouse_pressed; + self.left_mouse_dragging = + (self.left_mouse_pressed || self.left_mouse_dragging) && left_mouse_pressed; + self.left_mouse_pressed = + left_mouse_pressed && !(self.left_mouse_pressed || self.left_mouse_dragging); + + let alt_mouse_clicked = self + .io + .emulate_three_button_mouse + .is_active(&ui.ctx().input().modifiers) + || self + .io + .alt_mouse_button + .map_or(false, |x| ui.ctx().input().pointer.button_down(x)); + self.alt_mouse_dragging = + (self.alt_mouse_clicked || self.alt_mouse_dragging) && alt_mouse_clicked; + self.alt_mouse_clicked = + alt_mouse_clicked && !(self.alt_mouse_clicked || self.alt_mouse_dragging); + self.link_detach_with_modifier_click = self + .io + .link_detach_with_modifier_click + .is_active(&ui.ctx().input().modifiers); + + if self.mouse_in_canvas { + self.resolve_occluded_pins(); + self.resolve_hovered_pin(); + + if self.hovered_pin_id.is_none() { + self.resolve_hovered_node(); + } + } + + self.click_interaction_update(ui); + + if self.mouse_in_canvas && self.hovered_node_id.is_none() { + self.resolve_hovered_link(); + } + + for node_id in self.node_depth_order.clone() { + self.draw_node(node_id, ui); + } + + let link_ids = self.links.keys().cloned().collect::>(); + for link_id in link_ids { + self.draw_link(link_id, ui); + } + + if self.left_mouse_pressed || self.alt_mouse_clicked { + self.begin_canvas_interaction(); + } + + self.nodes.retain(|node_id, node| { + if node.in_use { + node.pin_ids.clear(); + true + } else { + self.node_depth_order.retain(|id| id != node_id); + false + } + }); + + self.pins.retain(|_, pin| pin.in_use); + self.links.retain(|_, link| link.in_use); + + ui.painter().rect_stroke( + self.canvas_rect_screen_space, + 0.0, + (1.0, self.style.colors[ColorStyle::GridLine as usize]), + ); + + response + } + + pub fn style_mut(&mut self) -> &mut Style { + &mut self.style + } + + pub fn node_pos(&self, node_id: Uuid) -> Option { + self.nodes.get(&node_id).map(|node| node.origin) + } + + /// Check if there is a node that is hovered by the pointer + pub fn hovered_node(&self) -> Option { + self.hovered_node_id + } + + /// Check if there is a link that is hovered by the pointer + pub fn hovered_link(&self) -> Option { + self.hovered_link_id + } + + /// Check if there is a pin that is hovered by the pointer + pub fn hovered_pin(&self) -> Option { + self.hovered_pin_id + } + + pub fn num_selected_nodes(&self) -> usize { + self.selected_link_ids.len() + } + + pub fn get_selected_nodes(&self) -> &[Uuid] { + &self.selected_node_ids + } + + pub fn get_selected_links(&self) -> &[Uuid] { + &self.selected_link_ids + } + + pub fn clear_node_selection(&mut self) { + self.selected_node_ids.clear() + } + + pub fn clear_link_selection(&mut self) { + self.selected_link_ids.clear() + } + + pub fn active_attribute(&self) -> Option { + if self.active_attribute { + Some(self.active_attribute_id) + } else { + None + } + } + + /// Has a new link been created from a pin? + pub fn started_link_pin(&self) -> Option { + if (self.element_state_change & ElementStateChange::LinkStarted as usize) != 0 { + Some(self.click_interaction_state.link_creation.start_pin_id) + } else { + None + } + } + + /// Has a detached link been dropped? + pub fn dropped_link(&self) -> Option { + self.dropped_link_id + } + + pub fn created_link(&self) -> Option<(Uuid, Uuid, bool)> { + if (self.element_state_change & ElementStateChange::LinkCreated as usize) != 0 { + let mut start_pin_id = self.click_interaction_state.link_creation.start_pin_id; + let mut end_pin_id = self + .click_interaction_state + .link_creation + .end_pin_id + .unwrap(); + + if self.pins.get(&start_pin_id).unwrap().kind != AttributeKind::Output { + std::mem::swap(&mut start_pin_id, &mut end_pin_id); + } + + let created_from_snap = + self.click_interaction_type == ClickInteractionType::LinkCreation; + + Some((start_pin_id, end_pin_id, created_from_snap)) + } else { + None + } + } + + pub fn detached_link(&self) -> Option { + self.detached_link_id + } + + pub fn panning(&self) -> Vec2 { + self.panning + } + + pub fn reset_panning(&mut self, panning: Vec2) { + self.panning = panning; + } + + pub fn node_dimensions(&self, id: Uuid) -> Option { + self.nodes.iter().find_map(|(&node_id, node)| { + if node_id == id { + Some(node.rect.size()) + } else { + None + } + }) + } +} + +impl Context { + pub fn add_node(&mut self, id: Uuid) -> NodeBuilder { + NodeBuilder::new(self, id) + } + + pub(crate) fn show_node<'a>( + &'a mut self, + NodeBuilder { + id: node_id, + header_contents, + attributes, + pos, + .. + }: NodeBuilder<'a>, + ui: &mut Ui, + ) { + let node: &mut Node = self.nodes.entry(node_id).or_insert_with(|| { + let mut node = Node::new(); + if let Some(pos) = pos { + node.origin = pos; + } + debug!( + "New node created at ({}, {}): {}", + node.origin.x, node.origin.y, node_id + ); + + if !self.node_depth_order.contains(&node_id) { + self.node_depth_order.push(node_id); + } + node + }); + node.in_use = true; + + self.style.format_node(node); + node.background_shape + .replace(ui.painter().add(egui::Shape::Noop)); + + let node_origin = node.origin; + let node_size = node.size; + let title_space = node.layout_style.padding.y; + + node.header_shapes.push(ui.painter().add(egui::Shape::Noop)); + node.header_shapes.push(ui.painter().add(egui::Shape::Noop)); + let mut header_content_rect = node.header_content_rect; + + let padding = node.layout_style.padding; + let node_pos = self.grid_space_to_screen_space(node_origin); + + let response = ui.allocate_ui_at_rect(Rect::from_min_size(node_pos, node_size), |ui| { + if let Some(header_contents) = header_contents { + let response = ui.allocate_ui(ui.available_size(), header_contents); + header_content_rect = response.response.rect; + + ui.add_space(title_space); + } + + ui.allocate_space(Vec2::splat(4.0)); + + for NodeAttribute { + id: attr_id, + kind, + pin_args, + add_contents, + } in attributes + { + let response = ui.allocate_ui(ui.available_size(), add_contents); + let response = response.response.union(response.inner); + self.add_attribute(attr_id, node_id, kind, pin_args, response); + } + + ui.rect_contains_pointer(ui.min_rect().expand2(padding)) + }); + + let node: &mut Node = self.nodes.get_mut(&node_id).unwrap(); + + node.rect = response.response.rect.expand2(padding); + node.header_content_rect = header_content_rect.expand2(padding); + + node.header_content_rect.max.x = node.rect.max.x; + + let hovered = response.inner; + if hovered { + self.node_ids_overlapping_with_mouse.push(node_id); + } + } + + fn add_attribute( + &mut self, + pin_id: Uuid, + node_id: Uuid, + kind: AttributeKind, + args: PinArgs, + response: egui::Response, + ) { + if kind != AttributeKind::None { + let pin = self.pins.entry(pin_id).or_default(); + pin.in_use = true; + pin.parent_node_id = node_id; + pin.kind = kind; + pin.attribute_rect = response.rect; + + self.style.format_pin(pin, args); + self.nodes.get_mut(&node_id).unwrap().add_pin(pin_id); + } + + if response.is_pointer_button_down_on() { + self.active_attribute = true; + self.active_attribute_id = pin_id; + self.interactive_node_id.replace(node_id); + } + } + + pub fn add_link( + &mut self, + id: Uuid, + start_pin_id: Uuid, + end_pin_id: Uuid, + args: LinkArgs, + ui: &mut Ui, + ) { + self.end_pin_link_mapping + .entry(end_pin_id) + .or_default() + .push(id); + + let link: &mut LinkData = self.links.entry(id).or_default(); + link.in_use = true; + link.start_pin_id = start_pin_id; + link.end_pin_id = end_pin_id; + + link.shape.replace(ui.painter().add(egui::Shape::Noop)); + self.style.format_link(link, args); + + if (self.click_interaction_type == ClickInteractionType::LinkCreation + && self + .pins + .get(&link.end_pin_id) + .unwrap() + .link_creation_on_snap_enabled() + && self.click_interaction_state.link_creation.start_pin_id == link.start_pin_id + && self.click_interaction_state.link_creation.end_pin_id == Some(link.end_pin_id)) + || (self.click_interaction_state.link_creation.start_pin_id == link.end_pin_id + && self.click_interaction_state.link_creation.end_pin_id == Some(link.start_pin_id)) + { + self.snap_link_id.replace(id); + } + } + + fn draw_grid(&self, canvas_size: Vec2, ui: &mut Ui) { + let mut y = self.panning.y.rem_euclid(self.style.grid_spacing); + while y < canvas_size.y { + let mut x = self.panning.x.rem_euclid(self.style.grid_spacing); + while x < canvas_size.x { + ui.painter().circle_filled( + self.editor_space_to_screen_space([x, y].into()), + 2.0, + self.style.colors[ColorStyle::GridLine as usize], + ); + x += self.style.grid_spacing; + } + + y += self.style.grid_spacing; + } + } + + fn grid_space_to_screen_space(&self, v: Pos2) -> Pos2 { + v + self.canvas_origin_screen_space + self.panning + } + + fn editor_space_to_screen_space(&self, v: Pos2) -> Pos2 { + v + self.canvas_origin_screen_space + } + + fn get_screen_space_pin_coordinates(&self, pin: &PinData) -> Pos2 { + let parent_node_rect = self.nodes.get(&pin.parent_node_id).unwrap().rect; + self.style.get_screen_space_pin_coordinates( + &parent_node_rect, + &pin.attribute_rect, + pin.kind, + ) + } + + fn resolve_occluded_pins(&mut self) { + self.occluded_pin_ids.clear(); + + let depth_stack = &self.node_depth_order; + if depth_stack.len() < 2 { + return; + } + + for depth_idx in 0..(depth_stack.len() - 1) { + let node_below = self.nodes.get(&depth_stack[depth_idx]).unwrap(); + for next_depth in &depth_stack[(depth_idx + 1)..(depth_stack.len())] { + let rect_above = self.nodes.get(next_depth).unwrap().rect; + for pin_id in node_below.pin_ids.iter() { + let pin_pos = self.pins.get(pin_id).unwrap().pos; + if rect_above.contains(pin_pos) { + self.occluded_pin_ids.push(*pin_id); + } + } + } + } + } + + fn resolve_hovered_pin(&mut self) { + let mut smallest_distance = f32::MAX; + self.hovered_pin_id.take(); + + let hover_radius_sqr = self.style.pin_hover_radius.powi(2); + + for (pin_id, pin) in self.pins.iter() { + if self.occluded_pin_ids.contains(pin_id) { + continue; + } + + let distance_sqr = (pin.pos - self.mouse_pos).length_sq(); + if distance_sqr < hover_radius_sqr && distance_sqr < smallest_distance { + smallest_distance = distance_sqr; + self.hovered_pin_id.replace(*pin_id); + } + } + } + + fn resolve_hovered_node(&mut self) { + match self.node_ids_overlapping_with_mouse.len() { + 0 => { + self.hovered_node_id.take(); + } + 1 => { + self.hovered_node_id + .replace(self.node_ids_overlapping_with_mouse[0]); + } + _ => { + let mut largest_depth_idx = -1; + + for node_id in self.node_ids_overlapping_with_mouse.iter() { + for (depth_idx, depth_node_id) in self.node_depth_order.iter().enumerate() { + if *depth_node_id == *node_id && depth_idx as isize > largest_depth_idx { + largest_depth_idx = depth_idx as isize; + self.hovered_node_id.replace(*node_id); + } + } + } + } + } + } + + fn resolve_hovered_link(&mut self) { + let mut smallest_distance = f32::MAX; + self.hovered_link_id.take(); + + let links_clone = self.links.clone(); + for (&link_id, link) in self.links.iter_mut() { + if !self.pins.contains_key(&link.start_pin_id) + || !self.pins.contains_key(&link.end_pin_id) + { + continue; + } + + let start_pin = self.pins.get(&link.start_pin_id).unwrap(); + let end_pin = self.pins.get(&link.end_pin_id).unwrap(); + + let pin_link_count = Self::link_count_for_end_pin( + &self.end_pin_link_mapping, + link.end_pin_id, + &self.partial_link, + ); + let idx = Self::link_index_for_end_pin( + &self.end_pin_link_mapping, + &links_clone, + &self.pins, + &self.partial_link, + link.end_pin_id, + link_id, + start_pin.pos, + ) + .unwrap_or(0); + + let end_pos = if self.hovered_pin_id == Some(link.end_pin_id) && pin_link_count > 1 { + self.style + .calculate_link_end_pos(end_pin.pos, self.mouse_pos, pin_link_count, idx) + } else { + end_pin.pos + }; + + let link_data = LinkBezierData::build( + start_pin.pos, + end_pos, + start_pin.kind, + self.style.link_line_segments_per_length, + ); + + let distance = link_data.get_distance_to_cubic_bezier(&self.mouse_pos); + + if distance < self.style.link_hover_distance && distance < smallest_distance { + smallest_distance = distance; + self.hovered_link_id.replace(link_id); + } + } + } + + fn link_count_for_end_pin( + end_pin_links: &HashMap>, + pin_id: Uuid, + partial_link: &Option<(Uuid, Option)>, + ) -> usize { + let mut count = end_pin_links + .get(&pin_id) + .map(|links| links.len()) + .unwrap_or(0); + + match partial_link { + Some((start_pin_id, Some(partial_link_end_pin_id))) + if *partial_link_end_pin_id == pin_id && *start_pin_id != pin_id => + { + count += 1; + } + _ => {} + } + + count + } + + fn link_index_for_end_pin( + end_pin_links: &HashMap>, + links: &IndexMap, + pins: &IndexMap, + partial_link: &Option<(Uuid, Option)>, + pin_id: Uuid, + link_id: Uuid, + start_pos: Pos2, + ) -> Option { + let end_pin_pos = pins.get(&pin_id)?.pos; + + let link_ids = end_pin_links.get(&pin_id)?; + let mut link_angles = link_ids + .iter() + .filter(|&id| *id != link_id) + .filter_map(|link_id| links.get(link_id).map(|link| (*link_id, link.start_pin_id))) + .filter_map(|(link_id, pin_id)| pins.get(&pin_id).map(|pin| (link_id, pin.pos))) + .chain([(link_id, start_pos)]) + .map(|(link_id, start_pin_pos)| (link_id, (end_pin_pos - start_pin_pos).angle())) + .collect::>(); + + match partial_link { + Some((start_pin_id, Some(end_pin_id))) + if *end_pin_id == pin_id && *start_pin_id != pin_id => + { + link_angles.push(( + Uuid::nil(), + (end_pin_pos - pins.get(start_pin_id).unwrap().pos).angle(), + )); + } + _ => {} + } + + link_angles.sort_by(|(_, angle1), (_, angle2)| { + angle2.partial_cmp(angle1).unwrap_or(Ordering::Equal) + }); + + link_angles.iter().position(|(id, _)| *id == link_id) + } + + fn draw_link(&mut self, link_id: Uuid, ui: &mut Ui) { + let links_clone = self.links.clone(); + let link = self.links.get_mut(&link_id).unwrap(); + + if !link.in_use + || !self.pins.contains_key(&link.start_pin_id) + || !self.pins.contains_key(&link.end_pin_id) + { + return; + } + + let same_pin_link_count = Self::link_count_for_end_pin( + &self.end_pin_link_mapping, + link.end_pin_id, + &self.partial_link, + ); + let idx = Self::link_index_for_end_pin( + &self.end_pin_link_mapping, + &links_clone, + &self.pins, + &self.partial_link, + link.end_pin_id, + link_id, + self.pins.get(&link.start_pin_id).unwrap().pos, + ) + .unwrap_or(0); + + let start_pin = self.pins.get(&link.start_pin_id).unwrap(); + let end_pin = self.pins.get(&link.end_pin_id).unwrap(); + let hovered_pin_id = self.hovered_pin_id; + + let end_pos = if hovered_pin_id == Some(link.end_pin_id) && same_pin_link_count > 1 { + self.style + .calculate_link_end_pos(end_pin.pos, self.mouse_pos, same_pin_link_count, idx) + } else { + end_pin.pos + }; + + let link_bezier_data = LinkBezierData::build( + start_pin.pos, + end_pos, + start_pin.kind, + self.style.link_line_segments_per_length, + ); + let link_shape = link.shape.take().unwrap(); + let link_hovered = self.hovered_link_id == Some(link_id) + && self.click_interaction_type != ClickInteractionType::BoxSelection; + + if link_hovered && self.left_mouse_pressed { + self.begin_link_interaction(link_id); + } + + if self.detached_link_id == Some(link_id) { + return; + } + + let link = self.links.get(&link_id).unwrap(); + let mut link_color = link.color_style.base; + if self.partial_link.is_none() { + if self.selected_link_ids.contains(&link_id) { + link_color = link.color_style.selected; + } else if link_hovered { + link_color = link.color_style.hovered; + } + } + + ui.painter().set( + link_shape, + link_bezier_data.draw((self.style.link_thickness, link_color)), + ); + } + + fn draw_node(&mut self, node_id: Uuid, ui: &mut Ui) { + let node: &mut Node = self.nodes.get_mut(&node_id).unwrap(); + if !node.in_use { + return; + } + + let node_hovered = self.hovered_node_id == Some(node_id) + && self.click_interaction_type != ClickInteractionType::BoxSelection; + + let (node_bg_color, title_bg_color) = if self.selected_node_ids.contains(&node_id) { + ( + node.color_style.background_selected, + node.color_style.header_selected, + ) + } else if node_hovered { + ( + node.color_style.background_hovered, + node.color_style.header_hovered, + ) + } else { + (node.color_style.background, node.color_style.header) + }; + + let painter = ui.painter(); + + if let Some(bg_shape) = node.background_shape.take() { + painter.set( + bg_shape, + egui::Shape::rect_filled( + node.rect, + node.layout_style.corner_rounding, + node_bg_color, + ), + ); + } + + if node.header_content_rect.height() > 0.0 { + if let Some(title_shape) = node.header_shapes.pop() { + painter.set( + title_shape, + egui::Shape::rect_filled( + Rect::from_min_size( + node.header_content_rect.min, + Vec2::new( + node.header_content_rect.width(), + node.layout_style.corner_rounding * 2.0, + ), + ), + node.layout_style.corner_rounding, + title_bg_color, + ), + ); + } + + if let Some(title_shape) = node.header_shapes.pop() { + painter.set( + title_shape, + egui::Shape::rect_filled( + Rect::from_min_size( + node.header_content_rect.min + + Vec2::new(0.0, node.layout_style.corner_rounding), + node.header_content_rect.size() + - Vec2::new(0.0, node.layout_style.corner_rounding), + ), + 0.0, + title_bg_color, + ), + ); + } + } + + for pin_id in node.pin_ids.iter().cloned().collect::>() { + self.draw_pin(pin_id, ui); + } + + if node_hovered && self.left_mouse_pressed && self.interactive_node_id != Some(node_id) { + self.begin_node_selection(node_id); + } + } + + fn draw_pin(&mut self, pin_id: Uuid, ui: &mut Ui) { + let pin: &mut PinData = self.pins.get_mut(&pin_id).unwrap(); + let parent_node_rect = self.nodes.get(&pin.parent_node_id).unwrap().rect; + + pin.pos = self.style.get_screen_space_pin_coordinates( + &parent_node_rect, + &pin.attribute_rect, + pin.kind, + ); + + let mut pin_color = pin.color_style.background; + + let pin_hovered = self.hovered_pin_id == Some(pin_id) + && self.click_interaction_type != ClickInteractionType::BoxSelection; + let pin_shape = pin.shape; + let pin_pos = pin.pos; + + let attached_link_count = + Self::link_count_for_end_pin(&self.end_pin_link_mapping, pin_id, &self.partial_link); + + if pin_hovered { + self.hovered_pin_flags = pin.flags; + pin_color = pin.color_style.hovered; + + if self.left_mouse_pressed && (pin.is_output() || self.hovered_link_id.is_some()) { + self.begin_link_creation(pin_id); + } + } + + if pin_hovered && attached_link_count > 1 { + self.style.draw_hovered_pin( + attached_link_count, + pin_pos, + self.mouse_pos, + pin_shape, + pin_color, + ui, + ); + } else { + self.style.draw_pin( + pin_pos, + pin_shape, + pin_color, + self.style.pin_circle_radius, + ui, + ); + } + } + + fn begin_canvas_interaction(&mut self) { + let any_ui_element_hovered = self.hovered_node_id.is_some() + || self.hovered_link_id.is_some() + || self.hovered_pin_id.is_some(); + + let mouse_not_in_canvas = !self.mouse_in_canvas; + + if self.click_interaction_type != ClickInteractionType::None + || any_ui_element_hovered + || mouse_not_in_canvas + { + return; + } + + if self.alt_mouse_clicked { + self.click_interaction_type = ClickInteractionType::Panning; + } else { + self.click_interaction_type = ClickInteractionType::BoxSelection; + self.click_interaction_state.box_selection.min = self.mouse_pos; + } + } + + fn translate_selected_nodes(&mut self) { + if self.left_mouse_dragging { + let delta = self.mouse_delta; + for node_id in self.selected_node_ids.iter() { + let node = self.nodes.get_mut(node_id).unwrap(); + if node.draggable { + node.origin += delta; + } + } + } + } + + fn should_link_snap_to_pin( + &self, + start_pin: &PinData, + hovered_pin_id: Uuid, + duplicate_link: Option, + ) -> bool { + let end_pin = self.pins.get(&hovered_pin_id).unwrap(); + + if start_pin.parent_node_id == end_pin.parent_node_id { + return false; + } + + if start_pin.kind == end_pin.kind { + return false; + } + + if duplicate_link.map_or(false, |duplicate_id| { + Some(duplicate_id) != self.snap_link_id + }) { + return false; + } + + true + } + + fn box_selector_update_selection(&mut self) -> Rect { + let mut box_rect = self.click_interaction_state.box_selection; + if box_rect.min.x > box_rect.max.x { + std::mem::swap(&mut box_rect.min.x, &mut box_rect.max.x); + } + + if box_rect.min.y > box_rect.max.y { + std::mem::swap(&mut box_rect.min.y, &mut box_rect.max.y); + } + + self.selected_node_ids.clear(); + for (node_id, node) in self.nodes.iter() { + if node.in_use && box_rect.intersects(node.rect) { + self.selected_node_ids.push(*node_id); + } + } + + self.selected_link_ids.clear(); + for (&link_id, link) in self.links.iter().filter(|(_, link)| link.in_use) { + if !self.pins.contains_key(&link.start_pin_id) + || !self.pins.contains_key(&link.end_pin_id) + { + continue; + } + + let pin_start = self.pins.get(&link.start_pin_id).unwrap(); + let pin_end = self.pins.get(&link.end_pin_id).unwrap(); + let node_start_rect = self.nodes.get(&pin_start.parent_node_id).unwrap().rect; + let node_end_rect = self.nodes.get(&pin_end.parent_node_id).unwrap().rect; + let start = self.style.get_screen_space_pin_coordinates( + &node_start_rect, + &pin_start.attribute_rect, + pin_start.kind, + ); + let end = self.style.get_screen_space_pin_coordinates( + &node_end_rect, + &pin_end.attribute_rect, + pin_end.kind, + ); + + if self.rectangle_overlaps_link(&box_rect, &start, &end, pin_start.kind) { + self.selected_link_ids.push(link_id); + } + } + box_rect + } + + #[inline] + fn rectangle_overlaps_link( + &self, + rect: &Rect, + start: &Pos2, + end: &Pos2, + start_type: AttributeKind, + ) -> bool { + let mut lrect = Rect::from_min_max(*start, *end); + if lrect.min.x > lrect.max.x { + std::mem::swap(&mut lrect.min.x, &mut lrect.max.x); + } + + if lrect.min.y > lrect.max.y { + std::mem::swap(&mut lrect.min.y, &mut lrect.max.y); + } + + if rect.intersects(lrect) { + if rect.contains(*start) || rect.contains(*end) { + return true; + } + + let link_data = LinkBezierData::build( + *start, + *end, + start_type, + self.style.link_line_segments_per_length, + ); + return link_data.rectangle_overlaps_bezier(rect); + } + false + } + + fn click_interaction_update(&mut self, ui: &mut Ui) { + match self.click_interaction_type { + ClickInteractionType::BoxSelection => { + self.click_interaction_state.box_selection.max = self.mouse_pos; + let rect = self.box_selector_update_selection(); + + let box_selector_color = self.style.colors[ColorStyle::BoxSelector as usize]; + let box_selector_outline = + self.style.colors[ColorStyle::BoxSelectorOutline as usize]; + ui.painter() + .rect(rect, 0.0, box_selector_color, (1.0, box_selector_outline)); + + if self.left_mouse_released { + let mut ids = Vec::with_capacity(self.selected_node_ids.len()); + let depth_stack = &mut self.node_depth_order; + let selected_nodes = &self.selected_node_ids; + depth_stack.retain(|id| { + if selected_nodes.contains(id) { + ids.push(*id); + false + } else { + true + } + }); + self.node_depth_order.extend(ids); + self.click_interaction_type = ClickInteractionType::None; + } + } + ClickInteractionType::Node => { + self.translate_selected_nodes(); + if self.left_mouse_released { + self.click_interaction_type = ClickInteractionType::None; + } + } + ClickInteractionType::Link => { + if self.left_mouse_released { + self.click_interaction_type = ClickInteractionType::None; + } + } + ClickInteractionType::LinkCreation => { + let maybe_duplicate_link_id = self.hovered_pin_id.and_then(|hovered_pin_id| { + self.find_duplicate_link( + self.click_interaction_state.link_creation.start_pin_id, + hovered_pin_id, + ) + }); + + let should_snap = self.hovered_pin_id.map_or(false, |hovered_pin_id| { + let start_pin = self + .pins + .get(&self.click_interaction_state.link_creation.start_pin_id) + .unwrap(); + self.should_link_snap_to_pin(start_pin, hovered_pin_id, maybe_duplicate_link_id) + }); + + let snapping_pin_changed = self + .click_interaction_state + .link_creation + .end_pin_id + .map_or(false, |pin_id| self.hovered_pin_id != Some(pin_id)); + + if snapping_pin_changed && self.snap_link_id.is_some() { + self.begin_link_detach( + self.snap_link_id.unwrap(), + self.click_interaction_state + .link_creation + .end_pin_id + .unwrap(), + ); + } + + let start_pin = self + .pins + .get(&self.click_interaction_state.link_creation.start_pin_id) + .unwrap(); + let start_pos = self.get_screen_space_pin_coordinates(start_pin); + + self.partial_link = Some(( + self.click_interaction_state.link_creation.start_pin_id, + self.hovered_pin_id, + )); + + let end_pos = if should_snap { + let hovered_pin_id = self.hovered_pin_id.unwrap(); + + let pin_pos = self + .get_screen_space_pin_coordinates(self.pins.get(&hovered_pin_id).unwrap()); + + let same_pin_link_count = Self::link_count_for_end_pin( + &self.end_pin_link_mapping, + hovered_pin_id, + &self.partial_link, + ); + + let idx = Self::link_index_for_end_pin( + &self.end_pin_link_mapping, + &self.links, + &self.pins, + &self.partial_link, + hovered_pin_id, + Uuid::nil(), + start_pos, + ) + .unwrap_or(0); + + if same_pin_link_count > 1 { + self.style.calculate_link_end_pos( + pin_pos, + self.mouse_pos, + same_pin_link_count, + idx, + ) + } else { + pin_pos + } + } else { + self.mouse_pos + }; + + let link_data = LinkBezierData::build( + start_pos, + end_pos, + start_pin.kind, + self.style.link_line_segments_per_length, + ); + ui.painter().add(link_data.draw(( + self.style.link_thickness, + self.style.colors[ColorStyle::Link as usize], + ))); + + let link_creation_on_snap = self.hovered_pin_id.map_or(false, |hovered_pin_id| { + self.pins + .get(&hovered_pin_id) + .unwrap() + .link_creation_on_snap_enabled() + }); + + if !should_snap { + self.click_interaction_state.link_creation.end_pin_id.take(); + } + + let create_link = + should_snap && (self.left_mouse_released || link_creation_on_snap); + + if create_link && maybe_duplicate_link_id.is_none() { + if !self.left_mouse_released + && self.click_interaction_state.link_creation.end_pin_id + == self.hovered_pin_id + { + return; + } + self.element_state_change |= ElementStateChange::LinkCreated as usize; + self.click_interaction_state.link_creation.end_pin_id = self.hovered_pin_id; + } + + if self.left_mouse_released { + self.click_interaction_type = ClickInteractionType::None; + if !create_link { + self.element_state_change |= ElementStateChange::LinkDropped as usize; + if self + .click_interaction_state + .link_creation + .link_creation_type + == LinkCreationType::FromDetach + { + self.dropped_link_id = + self.click_interaction_state.link_creation.detached_link_id; + } + } + } + } + ClickInteractionType::Panning => { + if self.alt_mouse_dragging || self.alt_mouse_clicked { + self.panning += self.mouse_delta; + } else { + self.click_interaction_type = ClickInteractionType::None; + } + } + ClickInteractionType::None => (), + } + } + + fn begin_link_detach(&mut self, id: Uuid, detach_id: Uuid) { + self.click_interaction_state.link_creation.end_pin_id.take(); + + let link = self.links.get(&id).unwrap(); + self.click_interaction_state.link_creation.start_pin_id = if detach_id == link.start_pin_id + { + link.end_pin_id + } else { + link.start_pin_id + }; + self.detached_link_id.replace(id); + } + + fn begin_link_interaction(&mut self, id: Uuid) { + let link = self.links.get(&id).unwrap(); + + if self.click_interaction_type == ClickInteractionType::LinkCreation { + if (self.hovered_pin_flags & AttributeFlags::EnableLinkDetachWithDragClick as usize) + != 0 + { + self.begin_link_detach(id, self.hovered_pin_id.unwrap()); + self.click_interaction_state.link_creation.detached_link_id = Some(id); + self.click_interaction_state + .link_creation + .link_creation_type = LinkCreationType::FromDetach; + } + } else if self.link_detach_with_modifier_click { + let start_pin = self.pins.get(&link.start_pin_id).unwrap(); + let end_pin = self.pins.get(&link.end_pin_id).unwrap(); + let dist_to_start = start_pin.pos.distance(self.mouse_pos); + let dist_to_end = end_pin.pos.distance(self.mouse_pos); + let closest_pin_idx = if dist_to_start < dist_to_end { + link.start_pin_id + } else { + link.end_pin_id + }; + self.click_interaction_type = ClickInteractionType::LinkCreation; + self.begin_link_detach(id, closest_pin_idx); + } else { + self.begin_link_selection(id); + } + } + + fn begin_link_creation(&mut self, hovered_pin_id: Uuid) { + self.click_interaction_type = ClickInteractionType::LinkCreation; + self.click_interaction_state.link_creation.start_pin_id = hovered_pin_id; + self.click_interaction_state.link_creation.end_pin_id.take(); + self.click_interaction_state + .link_creation + .link_creation_type = LinkCreationType::Standard; + self.element_state_change |= ElementStateChange::LinkStarted as usize; + } + + fn begin_link_selection(&mut self, link_id: Uuid) { + self.click_interaction_type = ClickInteractionType::Link; + self.selected_node_ids.clear(); + self.selected_link_ids.clear(); + self.selected_link_ids.push(link_id); + } + + fn find_duplicate_link(&self, start_pin_id: Uuid, end_pin_id: Uuid) -> Option { + self.links.iter().find_map(|(&link_id, link)| { + if link.in_use && link.start_pin_id == start_pin_id && link.end_pin_id == end_pin_id { + Some(link_id) + } else { + None + } + }) + } + + fn begin_node_selection(&mut self, id: Uuid) { + if self.click_interaction_type != ClickInteractionType::None { + return; + } + self.click_interaction_type = ClickInteractionType::Node; + if !self.selected_node_ids.contains(&id) { + self.selected_node_ids.clear(); + self.selected_link_ids.clear(); + self.selected_node_ids.push(id); + + self.node_depth_order.retain(|depth_id| *depth_id != id); + self.node_depth_order.push(id); + } + } +} + +#[derive(Debug)] +enum ElementStateChange { + None = 0, + LinkStarted = 1 << 0, + LinkDropped = 1 << 1, + LinkCreated = 1 << 2, +} + +#[derive(PartialEq, Debug, Copy, Clone)] +enum ClickInteractionType { + Node, + Link, + LinkCreation, + Panning, + BoxSelection, + None, +} + +#[derive(PartialEq, Debug)] +enum LinkCreationType { + Standard, + FromDetach, +} + +#[derive(Derivative, Debug)] +#[derivative(Default)] +struct ClickInteractionStateLinkCreation { + start_pin_id: Uuid, + end_pin_id: Option, + detached_link_id: Option, + #[derivative(Default(value = "LinkCreationType::Standard"))] + link_creation_type: LinkCreationType, +} + +#[derive(Derivative, Debug)] +#[derivative(Default)] +struct ClickInteractionState { + link_creation: ClickInteractionStateLinkCreation, + #[derivative(Default(value = "[[0.0; 2].into(); 2].into()"))] + box_selection: Rect, +} + +/// This controls the modifiers needed for certain mouse interactions +#[derive(Derivative, Debug)] +#[derivative(Default)] +pub struct IO { + /// The Modifier that needs to pressed to pan the editor + #[derivative(Default(value = "Modifiers::None"))] + pub emulate_three_button_mouse: Modifiers, + + // The modifier that needs to be pressed to detach a link instead of creating a new one + #[derivative(Default(value = "Modifiers::None"))] + pub link_detach_with_modifier_click: Modifiers, + + // The mouse button that pans the editor. Should probably not be set to Primary. + #[derivative(Default(value = "Some(egui::PointerButton::Middle)"))] + pub alt_mouse_button: Option, +} + +/// Used to track which Egui Modifier needs to be pressed for certain IO actions +#[derive(Debug)] +pub enum Modifiers { + Alt, + Ctrl, + Shift, + Command, + None, +} + +impl Modifiers { + fn is_active(&self, mods: &egui::Modifiers) -> bool { + match self { + Modifiers::Alt => mods.alt, + Modifiers::Ctrl => mods.ctrl, + Modifiers::Shift => mods.shift, + Modifiers::Command => mods.command, + Modifiers::None => false, + } + } +} diff --git a/crates/nodio-gui-nodes/src/link.rs b/crates/nodio-gui-nodes/src/link.rs new file mode 100644 index 0000000..1845418 --- /dev/null +++ b/crates/nodio-gui-nodes/src/link.rs @@ -0,0 +1,248 @@ +use derivative::Derivative; +use egui::epaint::PathShape; + +use super::*; + +/// The Color Style of a Link. If fields are None then the Context style is used +#[derive(Default, Debug)] +pub struct LinkArgs { + pub base: Option, + pub hovered: Option, + pub selected: Option, +} + +impl LinkArgs { + pub const fn new() -> Self { + Self { + base: None, + hovered: None, + selected: None, + } + } +} + +#[derive(Default, Debug, Copy, Clone)] +pub struct LinkDataColorStyle { + pub base: egui::Color32, + pub hovered: egui::Color32, + pub selected: egui::Color32, +} + +#[derive(Derivative, Copy, Clone)] +#[derivative(Debug)] +pub struct LinkData { + pub in_use: bool, + pub start_pin_id: Uuid, + pub end_pin_id: Uuid, + #[derivative(Debug = "ignore")] + pub color_style: LinkDataColorStyle, + #[derivative(Debug = "ignore")] + pub shape: Option, +} + +impl LinkData { + pub fn new() -> Self { + Self { + in_use: true, + start_pin_id: Uuid::new_v4(), + end_pin_id: Uuid::new_v4(), + color_style: Default::default(), + shape: None, + } + } +} + +impl Default for LinkData { + fn default() -> Self { + Self::new() + } +} + +impl PartialEq for LinkData { + fn eq(&self, rhs: &Self) -> bool { + let mut lhs_start = self.start_pin_id; + let mut lhs_end = self.end_pin_id; + let mut rhs_start = rhs.start_pin_id; + let mut rhs_end = rhs.end_pin_id; + + if lhs_start > lhs_end { + std::mem::swap(&mut lhs_start, &mut lhs_end); + } + + if rhs_start > rhs_end { + std::mem::swap(&mut rhs_start, &mut rhs_end); + } + + lhs_start == rhs_start && lhs_end == rhs_start + } +} + +#[derive(Debug)] +pub struct BezierCurve(Pos2, Pos2, Pos2, Pos2); + +impl BezierCurve { + #[inline] + pub fn eval(&self, t: f32) -> Pos2 { + <[f32; 2]>::from( + (1.0 - t).powi(3) * self.0.to_vec2() + + 3.0 * (1.0 - t).powi(2) * t * self.1.to_vec2() + + 3.0 * (1.0 - t) * t.powi(2) * self.2.to_vec2() + + t.powi(3) * self.3.to_vec2(), + ) + .into() + } + + #[inline] + pub fn _get_containing_rect_for_bezier_curve(&self, hover_distance: f32) -> Rect { + let min = self.0.min(self.3); + let max = self.0.max(self.3); + + let mut rect = Rect::from_min_max(min, max); + rect.extend_with(self.1); + rect.extend_with(self.2); + rect.expand(hover_distance) + } +} + +#[derive(Debug)] +pub(crate) struct LinkBezierData { + pub bezier: BezierCurve, + pub num_segments: usize, +} + +impl LinkBezierData { + #[inline] + pub(crate) fn build( + start: Pos2, + end: Pos2, + start_type: AttributeKind, + line_segments_per_length: f32, + ) -> Self { + let (mut start, mut end) = (start, end); + if start_type == AttributeKind::Input { + std::mem::swap(&mut start, &mut end); + } + + let link_length = end.distance(start); + let offset = egui::vec2(0.25 * link_length, 0.0); + Self { + bezier: BezierCurve(start, start + offset, end - offset, end), + num_segments: 1.max((link_length * line_segments_per_length) as usize), + } + } + + pub(crate) fn get_closest_point_on_cubic_bezier(&self, p: &Pos2) -> Pos2 { + let mut p_last = self.bezier.0; + let mut p_closest = self.bezier.0; + let mut p_closest_dist = f32::MAX; + let t_step = 1.0 / self.num_segments as f32; + for i in 1..=self.num_segments { + let p_current = self.bezier.eval(t_step * i as f32); + let p_line = line_closest_point(&p_last, &p_current, p); + let dist = p.distance_sq(p_line); + if dist < p_closest_dist { + p_closest = p_line; + p_closest_dist = dist; + } + p_last = p_current; + } + p_closest + } + + #[inline] + pub(crate) fn get_distance_to_cubic_bezier(&self, pos: &Pos2) -> f32 { + let point_on_curve = self.get_closest_point_on_cubic_bezier(pos); + pos.distance(point_on_curve) + } + + #[inline] + pub(crate) fn rectangle_overlaps_bezier(&self, rect: &Rect) -> bool { + let mut current = self.bezier.eval(0.0); + let dt = 1.0 / self.num_segments as f32; + for i in 0..self.num_segments { + let next = self.bezier.eval((i + 1) as f32 * dt); + if rectangle_overlaps_line_segment(rect, ¤t, &next) { + return true; + } + current = next; + } + false + } + + pub(crate) fn draw(&self, stroke: impl Into) -> egui::Shape { + let points = std::iter::once(self.bezier.0) + .chain( + (1..self.num_segments) + .map(|x| self.bezier.eval(x as f32 / self.num_segments as f32)), + ) + .chain(std::iter::once(self.bezier.3)) + .collect(); + let path_shape = PathShape { + points, + closed: false, + fill: egui::Color32::TRANSPARENT, + stroke: stroke.into(), + }; + egui::Shape::Path(path_shape) + } +} + +#[inline] +pub fn line_closest_point(a: &Pos2, b: &Pos2, p: &Pos2) -> Pos2 { + let ap = *p - *a; + let ab_dir = *b - *a; + let dot = ap.x * ab_dir.x + ap.y * ab_dir.y; + if dot < 0.0 { + return *a; + } + let ab_len_sqr = ab_dir.x * ab_dir.x + ab_dir.y * ab_dir.y; + if dot > ab_len_sqr { + return *b; + } + *a + ab_dir * dot / ab_len_sqr +} + +#[inline] +fn eval_inplicit_line_eq(p1: &Pos2, p2: &Pos2, p: &Pos2) -> f32 { + (p2.y * p1.y) * p.x + (p1.x * p2.x) * p.y * (p2.x * p1.y - p1.x * p2.y) +} + +#[inline] +fn rectangle_overlaps_line_segment(rect: &Rect, p1: &Pos2, p2: &Pos2) -> bool { + if rect.contains(*p1) || rect.contains(*p2) { + return true; + } + + let mut flip_rect = *rect; + if flip_rect.min.x > flip_rect.max.x { + std::mem::swap(&mut flip_rect.min.x, &mut flip_rect.max.x); + } + + if flip_rect.min.y > flip_rect.max.y { + std::mem::swap(&mut flip_rect.min.y, &mut flip_rect.max.y); + } + + if (p1.x < flip_rect.min.x && p2.x < flip_rect.min.x) + || (p1.x > flip_rect.max.x && p2.x > flip_rect.max.x) + || (p1.y < flip_rect.min.y && p2.y < flip_rect.min.y) + || (p1.y > flip_rect.max.y && p2.y > flip_rect.max.y) + { + return false; + } + + let corner_signs = [ + eval_inplicit_line_eq(p1, p2, &flip_rect.left_bottom()).signum(), + eval_inplicit_line_eq(p1, p2, &flip_rect.left_top()).signum(), + eval_inplicit_line_eq(p1, p2, &flip_rect.right_bottom()).signum(), + eval_inplicit_line_eq(p1, p2, &flip_rect.right_top()).signum(), + ]; + + let mut sum = 0.0; + let mut sum_abs = 0.0; + for sign in corner_signs.iter() { + sum += sign; + sum_abs += sign.abs(); + } + + (sum.abs() - sum_abs).abs() < f32::EPSILON +} diff --git a/crates/nodio-gui-nodes/src/node.rs b/crates/nodio-gui-nodes/src/node.rs new file mode 100644 index 0000000..3cc3d4d --- /dev/null +++ b/crates/nodio-gui-nodes/src/node.rs @@ -0,0 +1,178 @@ +use super::*; +use derivative::Derivative; +use std::collections::HashSet; + +#[derive(Default, Debug)] +pub(crate) struct NodeColorStyle { + pub background: egui::Color32, + pub background_hovered: egui::Color32, + pub background_selected: egui::Color32, + pub header: egui::Color32, + pub header_hovered: egui::Color32, + pub header_selected: egui::Color32, +} + +#[derive(Default, Debug)] +pub struct NodeLayoutStyle { + pub corner_rounding: f32, + pub padding: Vec2, + pub border_thickness: f32, +} + +#[derive(Derivative)] +#[derivative(Debug)] +pub(crate) struct Node { + pub in_use: bool, + pub origin: Pos2, + pub size: Vec2, + pub header_content_rect: Rect, + pub rect: Rect, + #[derivative(Debug = "ignore")] + pub color_style: NodeColorStyle, + pub layout_style: NodeLayoutStyle, + pub pin_ids: HashSet, + pub draggable: bool, + + #[derivative(Debug = "ignore")] + pub header_shapes: Vec, + #[derivative(Debug = "ignore")] + pub background_shape: Option, +} + +impl Node { + pub fn new() -> Self { + Self { + in_use: true, + origin: [100.0; 2].into(), + size: [180.0; 2].into(), + header_content_rect: [[0.0; 2].into(); 2].into(), + rect: [[0.0; 2].into(); 2].into(), + color_style: Default::default(), + layout_style: Default::default(), + pin_ids: Default::default(), + draggable: true, + header_shapes: Vec::new(), + background_shape: None, + } + } + + pub fn add_pin(&mut self, pin_id: Uuid) { + self.pin_ids.insert(pin_id); + } +} + +impl Default for Node { + fn default() -> Self { + Self::new() + } +} + +pub(crate) struct NodeAttribute<'a> { + pub(crate) id: Uuid, + pub(crate) kind: AttributeKind, + pub(crate) pin_args: PinArgs, + pub(crate) add_contents: Box egui::Response + 'a>, +} + +/// Used to construct a node and stores the relevant ui code for its title and attributes +/// This is used so that the nodes can be rendered in the context depth order +#[derive(Derivative)] +#[derivative(Debug)] +pub struct NodeBuilder<'a> { + pub(crate) ctx: Option<&'a mut Context>, + + pub(crate) id: Uuid, + #[derivative(Debug = "ignore")] + pub(crate) header_contents: Option>, + #[derivative(Debug = "ignore")] + pub(crate) attributes: Vec>, + pub(crate) pos: Option, +} + +impl<'a> NodeBuilder<'a> { + /// Create a new node to be displayed in a [Context]. + /// Id should be the same across frames and should not be the same as any + /// other currently used nodes. + pub fn new(ctx: &'a mut Context, id: Uuid) -> Self { + Self { + ctx: Some(ctx), + id, + header_contents: None, + attributes: Vec::new(), + pos: None, + } + } + + /// Add a header with given contents. + pub fn with_header(mut self, add_contents: impl FnOnce(&mut Ui) + 'a) -> Self { + self.header_contents.replace(Box::new(add_contents)); + self + } + + /// Add an input attribute that can be connected to output attributes of other nodes. + /// Id should be the same across frames and should not be the same as any other currently used + /// attributes. + pub fn with_input_attribute( + &mut self, + id: Uuid, + pin_args: PinArgs, + add_contents: impl FnOnce(&mut Ui) -> egui::Response + 'a, + ) -> &mut Self { + self.attributes.push(NodeAttribute { + id, + kind: AttributeKind::Input, + pin_args, + add_contents: Box::new(add_contents), + }); + self + } + + /// Add an output attribute that can be connected to input attributes of other nodes. + /// Id should be the same across frames and should not be the same as any other currently used + /// attributes. + pub fn with_output_attribute( + &mut self, + id: Uuid, + pin_args: PinArgs, + add_contents: impl FnOnce(&mut Ui) -> egui::Response + 'a, + ) -> &mut Self { + self.attributes.push(NodeAttribute { + id, + kind: AttributeKind::Output, + pin_args, + add_contents: Box::new(add_contents), + }); + self + } + + /// Add a static attribute that cannot be connected to other attributes. + /// Id should be the same across frames and should not be the same as any other currently used + /// attributes. + pub fn with_static_attribute( + mut self, + id: Uuid, + add_contents: impl FnOnce(&mut Ui) -> egui::Response + 'a, + ) -> Self { + self.attributes.push(NodeAttribute { + id, + kind: AttributeKind::None, + pin_args: PinArgs::default(), + add_contents: Box::new(add_contents), + }); + self + } + + /// Set the position of the node in screen space when it is first created. + pub fn with_origin(mut self, origin: Pos2) -> Self { + self.pos.replace(origin); + self + } + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn show(mut self, ui: &mut Ui) { + self.ctx.take().unwrap().show_node(self, ui); + } +} diff --git a/crates/nodio-gui-nodes/src/pin.rs b/crates/nodio-gui-nodes/src/pin.rs new file mode 100644 index 0000000..0fee8df --- /dev/null +++ b/crates/nodio-gui-nodes/src/pin.rs @@ -0,0 +1,113 @@ +use super::*; +use derivative::Derivative; + +#[derive(Default, Debug)] +pub struct PinArgs { + pub shape: PinShape, + pub flags: Option, + pub background: Option, + pub hovered: Option, +} + +impl PinArgs { + pub const fn new() -> Self { + Self { + shape: PinShape::CircleFilled, + flags: None, + background: None, + hovered: None, + } + } +} + +#[derive(PartialEq, Clone, Copy, Debug)] +pub(crate) enum AttributeKind { + None, + Input, + Output, +} + +impl Default for AttributeKind { + fn default() -> Self { + Self::None + } +} + +/// Controls the shape of an attribute pin. +#[derive(Clone, Copy, Debug)] +pub enum PinShape { + Circle, + CircleFilled, + Triangle, + TriangleFilled, + Quad, + QuadFilled, +} + +impl Default for PinShape { + fn default() -> Self { + Self::CircleFilled + } +} + +/// Controls the way that attribute pins behave +#[derive(Debug)] +pub enum AttributeFlags { + None = 0, + + /// If there is a link on the node then it will detatch instead of creating a new one. + /// Requires handling of deleted links via Context::link_destroyed + EnableLinkDetachWithDragClick = 1 << 0, + + /// Visual snapping will trigger link creation / destruction + EnableLinkCreationOnSnap = 1 << 1, +} + +#[derive(Default, Debug)] +pub(crate) struct PinDataColorStyle { + pub background: egui::Color32, + pub hovered: egui::Color32, +} + +#[derive(Derivative)] +#[derivative(Debug)] +pub(crate) struct PinData { + pub in_use: bool, + pub parent_node_id: Uuid, + pub attribute_rect: Rect, + pub kind: AttributeKind, + pub shape: PinShape, + pub pos: Pos2, + pub flags: usize, + #[derivative(Debug = "ignore")] + pub color_style: PinDataColorStyle, +} + +impl Default for PinData { + fn default() -> Self { + Self::new() + } +} + +impl PinData { + pub fn new() -> Self { + Self { + in_use: true, + parent_node_id: Default::default(), + attribute_rect: [[0.0; 2].into(); 2].into(), + kind: AttributeKind::None, + shape: PinShape::CircleFilled, + pos: Default::default(), + flags: AttributeFlags::None as usize, + color_style: Default::default(), + } + } + + pub fn is_output(&self) -> bool { + self.kind == AttributeKind::Output + } + + pub fn link_creation_on_snap_enabled(&self) -> bool { + self.flags & AttributeFlags::EnableLinkCreationOnSnap as usize != 0 + } +} diff --git a/crates/nodio-gui-nodes/src/style.rs b/crates/nodio-gui-nodes/src/style.rs new file mode 100644 index 0000000..044f844 --- /dev/null +++ b/crates/nodio-gui-nodes/src/style.rs @@ -0,0 +1,300 @@ +use super::*; + +use egui::{remap, Pos2}; +use std::f32::consts::{FRAC_PI_4, FRAC_PI_8, PI}; + +/// Represents different color style values used by a Context +#[derive(Debug, Clone, Copy)] +pub enum ColorStyle { + NodeBackground = 0, + NodeBackgroundHovered, + NodeBackgroundSelected, + NodeHeader, + NodeHeaderHovered, + NodeHeaderSelected, + Link, + LinkHovered, + LinkSelected, + Pin, + PinHovered, + BoxSelector, + BoxSelectorOutline, + GridBackground, + GridLine, + Count, +} + +/// Represents different style values used by a Context +#[derive(Debug, Clone, Copy)] +pub enum StyleVar { + GridSpacing = 0, + NodeCornerRounding, + NodePaddingHorizontal, + NodePaddingVertical, + NodeBorderThickness, + LinkThickness, + LinkLineSegmentsPerLength, + LinkHoverDistance, + PinCircleRadius, + PinQuadSideLength, + PinTriangleSideLength, + PinLineThickness, + PinHoverRadius, + PinOffset, +} + +/// Controls some style aspects +#[derive(Debug)] +pub enum StyleFlags { + None = 0, + GridLines = 1 << 2, +} + +impl ColorStyle { + /// dark color style + pub fn colors_dark() -> [egui::Color32; ColorStyle::Count as usize] { + let mut colors = [egui::Color32::BLACK; ColorStyle::Count as usize]; + colors[ColorStyle::NodeBackground as usize] = + egui::Color32::from_rgba_unmultiplied(50, 50, 50, 255); + colors[ColorStyle::NodeBackgroundHovered as usize] = + egui::Color32::from_rgba_unmultiplied(75, 75, 75, 255); + colors[ColorStyle::NodeBackgroundSelected as usize] = + egui::Color32::from_rgba_unmultiplied(75, 75, 75, 255); + colors[ColorStyle::NodeHeader as usize] = + egui::Color32::from_rgba_unmultiplied(74, 74, 74, 255); + colors[ColorStyle::NodeHeaderHovered as usize] = + egui::Color32::from_rgba_unmultiplied(94, 94, 94, 255); + colors[ColorStyle::NodeHeaderSelected as usize] = + egui::Color32::from_rgba_unmultiplied(120, 120, 120, 255); + colors[ColorStyle::Link as usize] = + egui::Color32::from_rgba_unmultiplied(60, 133, 224, 255); + colors[ColorStyle::LinkHovered as usize] = + egui::Color32::from_rgba_unmultiplied(60, 150, 250, 255); + colors[ColorStyle::LinkSelected as usize] = + egui::Color32::from_rgba_unmultiplied(60, 150, 250, 255); + colors[ColorStyle::Pin as usize] = egui::Color32::from_rgba_unmultiplied(60, 133, 224, 255); + colors[ColorStyle::PinHovered as usize] = + egui::Color32::from_rgba_unmultiplied(53, 150, 250, 255); + colors[ColorStyle::BoxSelector as usize] = + egui::Color32::from_rgba_unmultiplied(61, 133, 224, 30); + colors[ColorStyle::BoxSelectorOutline as usize] = + egui::Color32::from_rgba_unmultiplied(61, 133, 224, 150); + colors[ColorStyle::GridBackground as usize] = egui::Color32::from_rgb(20, 20, 20); + colors[ColorStyle::GridLine as usize] = egui::Color32::from_rgb(26, 26, 26); + colors + } +} + +#[derive(Debug)] +pub struct Style { + pub grid_spacing: f32, + pub node_corner_rounding: f32, + pub node_padding_horizontal: f32, + pub node_padding_vertical: f32, + pub node_border_thickness: f32, + + pub link_thickness: f32, + pub link_line_segments_per_length: f32, + pub link_hover_distance: f32, + + pub pin_circle_radius: f32, + pub pin_quad_side_length: f32, + pub pin_triangle_side_length: f32, + pub pin_line_thickness: f32, + pub pin_hover_radius: f32, + pub pin_hover_shape_radius: f32, + pub pin_offset: f32, + + pub flags: usize, + pub colors: [egui::Color32; ColorStyle::Count as usize], +} + +impl Default for Style { + fn default() -> Self { + Self { + grid_spacing: 26.0, + node_corner_rounding: 4.0, + node_padding_horizontal: 8.0, + node_padding_vertical: 8.0, + node_border_thickness: 1.0, + link_thickness: 3.0, + link_line_segments_per_length: 0.1, + link_hover_distance: 10.0, + pin_circle_radius: 4.0, + pin_quad_side_length: 7.0, + pin_triangle_side_length: 9.5, + pin_line_thickness: 1.0, + pin_hover_radius: 25.0, + pin_hover_shape_radius: 15.0, + pin_offset: 0.0, + flags: StyleFlags::GridLines as usize, + colors: ColorStyle::colors_dark(), + } + } +} + +impl Style { + pub(crate) fn get_screen_space_pin_coordinates( + &self, + node_rect: &Rect, + attribute_rect: &Rect, + kind: AttributeKind, + ) -> Pos2 { + let x = match kind { + AttributeKind::Input => node_rect.min.x - self.pin_offset, + _ => node_rect.max.x + self.pin_offset, + }; + egui::pos2(x, 0.5 * (attribute_rect.min.y + attribute_rect.max.y)) + } + + pub(crate) fn draw_hovered_pin( + &self, + link_count: usize, + pin_pos: Pos2, + mouse_pos: Pos2, + pin_shape: PinShape, + pin_color: egui::Color32, + ui: &mut Ui, + ) { + ui.painter().add(egui::Shape::circle_stroke( + pin_pos, + self.hovered_pin_radius(pin_pos, mouse_pos), + (self.pin_line_thickness, pin_color), + )); + + self.draw_pin( + pin_pos, + pin_shape, + pin_color, + self.pin_circle_radius / 2.0, + ui, + ); + + for i in 0..link_count { + let pin_pos = self.calculate_link_end_pos(pin_pos, mouse_pos, link_count, i); + self.draw_pin(pin_pos, pin_shape, pin_color, self.pin_circle_radius, ui); + } + } + + pub(crate) fn draw_pin( + &self, + pin_pos: Pos2, + pin_shape: PinShape, + pin_color: egui::Color32, + pin_radius: f32, + ui: &mut Ui, + ) { + let painter = ui.painter(); + + match pin_shape { + PinShape::Circle => painter.add(egui::Shape::circle_stroke( + pin_pos, + pin_radius, + (self.pin_line_thickness, pin_color), + )), + PinShape::CircleFilled => { + painter.add(egui::Shape::circle_filled(pin_pos, pin_radius, pin_color)) + } + PinShape::Quad => painter.add(egui::Shape::rect_stroke( + Rect::from_center_size(pin_pos, [self.pin_quad_side_length / 2.0; 2].into()), + 0.0, + (self.pin_line_thickness, pin_color), + )), + PinShape::QuadFilled => painter.add(egui::Shape::rect_filled( + Rect::from_center_size(pin_pos, [self.pin_quad_side_length / 2.0; 2].into()), + 0.0, + pin_color, + )), + PinShape::Triangle => { + let sqrt_3 = 3f32.sqrt(); + let left_offset = -0.166_666_7 * sqrt_3 * self.pin_triangle_side_length; + let right_offset = 0.333_333_3 * sqrt_3 * self.pin_triangle_side_length; + let verticacl_offset = 0.5 * self.pin_triangle_side_length; + painter.add(egui::Shape::closed_line( + vec![ + pin_pos + (left_offset, verticacl_offset).into(), + pin_pos + (right_offset, 0.0).into(), + pin_pos + (left_offset, -verticacl_offset).into(), + ], + (self.pin_line_thickness, pin_color), + )) + } + PinShape::TriangleFilled => { + let sqrt_3 = 3f32.sqrt(); + let left_offset = -0.166_666_7 * sqrt_3 * self.pin_triangle_side_length; + let right_offset = 0.333_333_3 * sqrt_3 * self.pin_triangle_side_length; + let verticacl_offset = 0.5 * self.pin_triangle_side_length; + painter.add(egui::Shape::convex_polygon( + vec![ + pin_pos + (left_offset, verticacl_offset).into(), + pin_pos + (right_offset, 0.0).into(), + pin_pos + (left_offset, -verticacl_offset).into(), + ], + pin_color, + egui::Stroke::none(), + )) + } + }; + } + + pub(crate) fn hovered_pin_radius(&self, pin_pos: Pos2, mouse_pos: Pos2) -> f32 { + remap( + self.pin_hover_radius - (pin_pos - mouse_pos).length(), + 0.0..=(self.pin_hover_radius - self.pin_hover_shape_radius - 5.0), + 0.0..=self.pin_hover_shape_radius, + ) + .min(self.pin_hover_shape_radius) + } + + pub(crate) fn calculate_link_end_pos( + &self, + pin_pos: Pos2, + mouse_pos: Pos2, + link_count: usize, + link_index: usize, + ) -> Pos2 { + let ang = PI - ((link_count - 1) as f32 * FRAC_PI_8) + (link_index as f32 * FRAC_PI_4); + let pin_radius = self.hovered_pin_radius(pin_pos, mouse_pos); + + pos2( + pin_pos.x + f32::cos(ang) * pin_radius, + pin_pos.y - f32::sin(ang) * pin_radius, + ) + } + + pub(crate) fn format_node(&self, node: &mut Node) { + node.color_style.background = self.colors[ColorStyle::NodeBackground as usize]; + node.color_style.background_hovered = + self.colors[ColorStyle::NodeBackgroundHovered as usize]; + node.color_style.background_selected = + self.colors[ColorStyle::NodeBackgroundSelected as usize]; + node.color_style.header = self.colors[ColorStyle::NodeHeader as usize]; + node.color_style.header_hovered = self.colors[ColorStyle::NodeHeaderHovered as usize]; + node.color_style.header_selected = self.colors[ColorStyle::NodeHeaderSelected as usize]; + node.layout_style.corner_rounding = self.node_corner_rounding; + node.layout_style.padding = + Vec2::new(self.node_padding_horizontal, self.node_padding_vertical); + node.layout_style.border_thickness = self.node_border_thickness; + } + + pub(crate) fn format_pin(&self, pin: &mut PinData, args: PinArgs) { + pin.shape = args.shape; + pin.flags = args.flags.unwrap_or(0); + pin.color_style.background = args + .background + .unwrap_or(self.colors[ColorStyle::Pin as usize]); + pin.color_style.hovered = args + .hovered + .unwrap_or(self.colors[ColorStyle::PinHovered as usize]); + } + + pub(crate) fn format_link(&self, link: &mut LinkData, args: LinkArgs) { + link.color_style.base = args.base.unwrap_or(self.colors[ColorStyle::Link as usize]); + link.color_style.hovered = args + .hovered + .unwrap_or(self.colors[ColorStyle::LinkHovered as usize]); + link.color_style.selected = args + .selected + .unwrap_or(self.colors[ColorStyle::LinkSelected as usize]); + } +} diff --git a/crates/nodio-win32/Cargo.toml b/crates/nodio-win32/Cargo.toml new file mode 100644 index 0000000..550383b --- /dev/null +++ b/crates/nodio-win32/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "nodio-win32" +version = "0.1.0" +edition = "2021" + +[dependencies] +nodio-core = { path = "../nodio-core" } + +widestring = "1.0.0-beta.1" +log = "0.4.17" +notify-thread = { git = "https://github.com/urholaukkarinen/notify-thread.git" } +pollster = "0.2.5" +parking_lot = { version = "0.12.0", features = ["deadlock_detection"] } + +[dependencies.windows] +version = "0.37.0" +features = [ + "alloc", + "interface", + "implement", + "Win32_Foundation", + "Win32_Security", + "Win32_Media_Audio", + "Win32_Media_Multimedia", + "Win32_Media_Audio_Endpoints", + "Win32_Media_KernelStreaming", + "Win32_Media_MediaFoundation", + "Win32_System_WinRT", + "Win32_System_Threading", + "Win32_System_ProcessStatus", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Ole", + "Win32_System_Threading", + "Win32_System_Registry", + "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", + "Win32_Devices_FunctionDiscovery", +] \ No newline at end of file diff --git a/crates/nodio-win32/src/com.rs b/crates/nodio-win32/src/com.rs new file mode 100644 index 0000000..167faa4 --- /dev/null +++ b/crates/nodio-win32/src/com.rs @@ -0,0 +1,43 @@ +use std::marker::PhantomData; + +use std::ptr::null; +use windows::core::Result; +use windows::Win32::Foundation::RPC_E_CHANGED_MODE; +use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED}; + +thread_local!(static COM_INITIALIZED: ComInitialized = { + unsafe { + let result = CoInitializeEx(null(), COINIT_APARTMENTTHREADED); + + + match result { + Err(err) if err.code() != RPC_E_CHANGED_MODE => { + panic!("Failed to initialize COM: {}", err.message()) + + }, + _ => ComInitialized { + result, + _ptr: PhantomData, + } + } + } +}); + +struct ComInitialized { + result: Result<()>, + _ptr: PhantomData<*mut ()>, +} + +impl Drop for ComInitialized { + #[inline] + fn drop(&mut self) { + if self.result.is_ok() { + unsafe { CoUninitialize() } + } + } +} + +#[inline] +pub fn ensure_com_initialized() { + COM_INITIALIZED.with(|_| {}); +} diff --git a/crates/nodio-win32/src/context.rs b/crates/nodio-win32/src/context.rs new file mode 100644 index 0000000..1847fff --- /dev/null +++ b/crates/nodio-win32/src/context.rs @@ -0,0 +1,649 @@ +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use log::{debug, error, info, trace, warn}; +use notify_thread::JoinHandle; +use parking_lot::RwLock; +use windows::core::HSTRING; +use windows::Win32::Media::Audio::{ + eCapture, eConsole, eMultimedia, eRender, EDataFlow, DEVICE_STATEMASK_ALL, +}; +use windows::Win32::System::Threading::GetCurrentProcessId; + +use nodio_core::{Context, DeviceInfo, Node, NodeKind, ProcessInfo, Uuid}; +use nodio_core::{Error, Result}; + +use crate::com::ensure_com_initialized; +use crate::custom::{ + create_audio_policy_config, AudioPolicyConfig, AudioSessionEvent, SessionState, +}; +use crate::device::{ + AudioDevice, DEVINTERFACE_AUDIO_CAPTURE, DEVINTERFACE_AUDIO_RENDER, MMDEVAPI_TOKEN, +}; +use crate::enumerator::AudioDeviceEnumerator; +use crate::loopback::LoopbackSession; +use crate::node::{NodeConnectionInfo, NodeConnectionKind}; +use crate::session::{session_node_match, AudioSession, AudioSessionKind}; + +pub struct Win32Context { + device_enumerator: AudioDeviceEnumerator, + audio_policy_config: Box, + + nodes: Vec, + + node_connections: Vec, + + loopback_sessions: Arc>>, + + sessions: Arc>>, + input_devices: Arc>>, + output_devices: Arc>>, + + session_update_thread: Option>, +} + +unsafe impl Send for Win32Context {} +unsafe impl Sync for Win32Context {} + +impl Drop for Win32Context { + fn drop(&mut self) { + self.session_update_thread.take().and_then(|thread| { + thread.notify(); + thread.join().ok() + }); + } +} + +impl Win32Context { + pub fn new() -> Arc> { + ensure_com_initialized(); + + let device_enumerator = AudioDeviceEnumerator::create().unwrap(); + + let ctx = Arc::new(RwLock::new(Win32Context { + device_enumerator, + audio_policy_config: create_audio_policy_config(), + nodes: vec![], + sessions: Default::default(), + input_devices: Default::default(), + output_devices: Default::default(), + node_connections: Default::default(), + loopback_sessions: Default::default(), + session_update_thread: None, + })); + + let mut output_devices = ctx + .read() + .device_enumerator + .enumerate_audio_endpoints(eRender, DEVICE_STATEMASK_ALL) + .unwrap(); + + let mut input_devices = ctx + .read() + .device_enumerator + .enumerate_audio_endpoints(eCapture, DEVICE_STATEMASK_ALL) + .unwrap(); + + for device in input_devices.iter_mut().chain(output_devices.iter_mut()) { + let ctx = ctx.clone(); + let name = device.name().to_string(); + + device.set_session_notification_callback(move |event| { + trace!("Session notification in {}: {:?}", name, event); + + Self::refresh_sessions(ctx.clone()); + }); + } + + ctx.write().input_devices = Arc::new(RwLock::new(input_devices)); + ctx.write().output_devices = Arc::new(RwLock::new(output_devices)); + + Self::refresh_sessions(ctx.clone()); + + let session_update_thread = { + let ctx = ctx.clone(); + + notify_thread::spawn(move |thread| { + trace!("Session update thread started"); + + while !thread.notified() { + let sessions: Arc<_> = ctx.read().sessions.clone(); + let input_devices: Arc<_> = ctx.read().input_devices.clone(); + let output_devices: Arc<_> = ctx.read().output_devices.clone(); + + for session in sessions.read().iter() { + if let Some(node) = ctx + .write() + .nodes + .iter_mut() + .find(|n| session_node_match(n, session)) + { + node.process_id = Some(session.process_id()); + node.peak_values = session.peak_values().unwrap_or((0.0, 0.0)); + node.volume = session.master_volume(); + node.active = session.is_active(); + node.present = true; + } + } + + for device in input_devices + .read() + .iter() + .chain(output_devices.read().iter()) + { + if let Some(node) = + ctx.write().nodes.iter_mut().find(|n| n.id == device.id()) + { + node.peak_values = device.peak_values().unwrap_or((0.0, 0.0)); + node.volume = device.master_volume(); + node.active = device.is_active(); + node.present = true; + } + } + + thread::sleep(Duration::from_secs_f32(1.0 / 30.0)); + } + + trace!("Session update thread stopped"); + }) + }; + + ctx.write().session_update_thread = Some(session_update_thread); + + ctx + } + + fn refresh_sessions(ctx: Arc>) { + debug!("Refreshing sessions"); + + let mut sessions = Vec::new(); + + for device in ctx + .read() + .output_devices + .read() + .iter() + .filter(|d| d.is_active()) + { + let device_sessions = match device.enumerate_sessions() { + Ok(sessions) => sessions, + Err(err) => { + error!("Failed to get device sessions: {}", err); + continue; + } + }; + + for mut session in device_sessions { + let pid = session.process_id(); + if pid == 0 { + continue; + } + + session.set_event_callback({ + let ctx = ctx.clone(); + let session = session.clone(); + + move |event| { + trace!("Session event: {:?}", event); + match event { + AudioSessionEvent::VolumeChange { level, muted } => { + if let Some(node) = ctx + .write() + .nodes + .iter_mut() + .find(|n| session_node_match(n, &session)) + { + node.volume = level; + node.muted = muted; + } + } + AudioSessionEvent::StateChange(state) => { + if let Some(node) = ctx + .write() + .nodes + .iter_mut() + .find(|n| session_node_match(n, &session)) + { + node.peak_values = (0.0, 0.0); + node.active = state == SessionState::Active; + node.present = state != SessionState::Expired; + } + } + AudioSessionEvent::Disconnect(reason) => { + trace!("Session disconnected. Reason: {:?}", reason); + + ctx.write() + .sessions + .write() + .retain(|s| s.id() != session.id()); + } + } + } + }); + + sessions.push(session); + } + } + + ctx.write().sessions = Arc::new(RwLock::new(sessions)); + } + + fn parse_mmdevice_id(mmdevice_id: &str) -> Option<(Uuid, EDataFlow)> { + mmdevice_id + .split(MMDEVAPI_TOKEN) + .nth(1) + .and_then(|s| s.split_once('#')) + .and_then(|(device_id, data_flow)| { + let device_id = device_id.to_string(); + let device_id = Uuid::from_str(&device_id[1..device_id.len() - 1]).unwrap(); + + match data_flow { + DEVINTERFACE_AUDIO_RENDER => Some((device_id, eRender)), + DEVINTERFACE_AUDIO_CAPTURE => Some((device_id, eCapture)), + _ => None, + } + }) + } + + fn get_default_audio_endpoint_for_process( + &self, + process_id: u32, + ) -> windows::core::Result<(Uuid, EDataFlow)> { + let device_id = unsafe { + self.audio_policy_config.persistent_default_audio_endpoint( + process_id, + eRender, + eMultimedia, + )? + }; + + Ok(Self::parse_mmdevice_id(&device_id.to_string()).unwrap_or((Uuid::nil(), eRender))) + } + + fn use_system_default_audio_endpoint_for_process( + &self, + process_id: u32, + ) -> windows::core::Result<()> { + self.set_default_audio_endpoint_for_process(process_id, HSTRING::new()) + } + + fn set_default_audio_endpoint_for_process( + &self, + process_id: u32, + device_id: HSTRING, + ) -> windows::core::Result<()> { + unsafe { + self.audio_policy_config + .set_persistent_default_audio_endpoint( + process_id, + eRender, + eMultimedia, + device_id.clone(), + )?; + + self.audio_policy_config + .set_persistent_default_audio_endpoint(process_id, eRender, eConsole, device_id) + } + } + + fn connect_application_node(&mut self, node_id: Uuid, target_id: Uuid) -> Result<()> { + let node = self.nodes.iter().find(|n| n.id == node_id).unwrap(); + + if node.process_id.is_none() { + return Err(Error::CouldNotConnect("No such process".to_string())); + } + + let output_devices = self.output_devices.read(); + let target_device = output_devices.iter().find(|d| d.id() == target_id).unwrap(); + + let mut conn_info = NodeConnectionInfo { + id: Uuid::new_v4(), + src_id: node_id, + dst_id: target_id, + kind: NodeConnectionKind::DefaultEndpoint, + }; + + if self + .node_connections + .iter() + .any(|conn| conn.src_id == node_id) + { + info!("Already connected, using loopback for stream duplication"); + + let loopback_session = LoopbackSession::start( + node_id, + target_id, + node.process_id.unwrap(), + target_device.mmdevice(), + ) + .map_err(|err| { + error!("Could not start loopback session: {}", err); + Error::CouldNotConnect(err.to_string()) + })?; + + conn_info.kind = NodeConnectionKind::Loopback; + + self.loopback_sessions.write().push(loopback_session); + } else if let Some(session) = self + .sessions + .read() + .iter() + .find(|session| session_node_match(node, session)) + { + match self.get_default_audio_endpoint_for_process(session.process_id()) { + Ok((device_id, _)) => { + if device_id != target_device.id() { + if let Err(err) = self.set_default_audio_endpoint_for_process( + session.process_id(), + target_device.mmdevice_id(eRender), + ) { + error!( + "Failed to set audio endpoint for process {}: {:?}", + session.process_id(), + err + ); + return Err(Error::CouldNotConnect(err.to_string())); + } else { + debug!( + "Set default audio endpoint for process {}", + session.process_id() + ); + } + } else { + debug!("Endpoint is already the same"); + } + } + Err(err) => { + error!( + "Failed to get default endpoint for process {}: {}", + session.process_id(), + err + ); + } + } + } + + self.node_connections.push(conn_info); + + Ok(()) + } + + fn connect_input_device(&mut self, node_id: Uuid, target_id: Uuid) -> Result<()> { + let input_devices = self.input_devices.write(); + let output_devices = self.output_devices.read(); + + let input_device = input_devices + .iter() + .find(|device| device.id() == node_id) + .ok_or_else(|| Error::CouldNotConnect("no such input device found".to_string()))?; + + let output_device = output_devices + .iter() + .find(|device| device.id() == target_id) + .ok_or_else(|| Error::CouldNotConnect("no such output device found".to_string()))?; + + if let Err(err) = input_device.set_listen(Some(output_device)) { + warn!( + "Failed to enable listening on device {}: {}", + input_device.name(), + err + ); + return Err(Error::CouldNotConnect(err.to_string())); + } + + self.node_connections.push(NodeConnectionInfo { + id: Uuid::new_v4(), + src_id: node_id, + dst_id: target_id, + kind: NodeConnectionKind::Listen, + }); + + Ok(()) + } + + fn output_device_exists(&self, id: Uuid) -> bool { + self.output_devices.read().iter().any(|d| d.id() == id) + } +} + +impl Context for Win32Context { + fn add_node(&mut self, mut node: Node) { + if self.nodes.iter().any(|other| other.id == node.id) { + info!("Node already added: {}", &node.display_name); + return; + } + + if let Some(session) = self + .sessions + .read() + .iter() + .find(|&session| session_node_match(&node, session)) + { + node.process_id = Some(session.process_id()); + } + + self.nodes.push(node); + } + + fn remove_node(&mut self, node_id: Uuid) { + let connections = self + .node_connections + .iter() + .filter(|conn| conn.src_id == node_id || conn.dst_id == node_id) + .copied() + .collect::>(); + + for conn in connections { + self.disconnect_node(conn.src_id, conn.dst_id); + } + + self.nodes.retain(|node| node.id != node_id); + } + + fn nodes(&self) -> &[Node] { + self.nodes.as_slice() + } + + fn nodes_mut(&mut self) -> &mut [Node] { + &mut self.nodes + } + + fn connect_node(&mut self, node_id: Uuid, target_id: Uuid) -> Result<()> { + let node_kind = match self.nodes.iter().find(|n| n.id == node_id) { + Some(node) => node.kind, + None => { + warn!("No node found for id {}", node_id); + return Err(Error::CouldNotConnect("No such node found".to_string())); + } + }; + + if !self.output_device_exists(target_id) { + warn!("No output device found for node id: {}", target_id); + return Err(Error::NoSuchDevice); + } + + match node_kind { + NodeKind::Application => self.connect_application_node(node_id, target_id)?, + NodeKind::InputDevice => self.connect_input_device(node_id, target_id)?, + + NodeKind::OutputDevice => { + warn!("Output device cannot be used as an input!"); + return Err(Error::CouldNotConnect( + "Output device cannot be used as an input!".to_string(), + )); + } + } + + Ok(()) + } + + fn disconnect_node(&mut self, src_id: Uuid, dst_id: Uuid) { + let removed_connection = match self + .node_connections + .iter() + .position(|conn| conn.src_id == src_id && conn.dst_id == dst_id) + .map(|idx| self.node_connections.remove(idx)) + { + Some(conn) => conn, + None => { + warn!("No such connection found"); + return; + } + }; + + info!("Removed connection {} => {}", src_id, dst_id); + + let node = match self.nodes.iter().find(|node| node.id == src_id) { + Some(node) => node, + None => { + warn!("No such node found"); + return; + } + }; + + match node.kind { + NodeKind::Application => { + if node.process_id.is_none() { + return; + } + + match removed_connection.kind { + NodeConnectionKind::DefaultEndpoint => { + let next_src_connection = self + .node_connections + .iter_mut() + .find(|conn| conn.src_id == src_id); + + if let Some(next_conn) = next_src_connection { + if next_conn.kind == NodeConnectionKind::Loopback { + self.loopback_sessions.write().retain(|s| { + s.src_id != next_conn.src_id || s.dst_id != next_conn.dst_id + }); + } + + next_conn.kind = NodeConnectionKind::DefaultEndpoint; + + let target_mmdevice_id = self + .output_devices + .read() + .iter() + .find(|d| d.id() == next_conn.dst_id) + .map(|d| d.mmdevice_id(eRender)) + .unwrap(); + + self.set_default_audio_endpoint_for_process( + node.process_id.unwrap(), + target_mmdevice_id, + ) + .ok(); + } else { + self.use_system_default_audio_endpoint_for_process( + node.process_id.unwrap(), + ) + .ok(); + } + } + NodeConnectionKind::Loopback => { + self.loopback_sessions + .write() + .retain(|s| s.src_id != src_id || s.dst_id != dst_id); + } + _ => {} + } + } + + NodeKind::InputDevice => { + if let Some(device) = self + .input_devices + .write() + .iter_mut() + .find(|device| device.id() == src_id) + { + if let Err(err) = device.set_listen(None) { + warn!( + "Failed to enable listening on device {}: {}", + &device.name(), + err + ) + } + } else { + warn!("No input device found for id {}", src_id); + } + } + _ => {} + } + } + + fn set_volume(&mut self, node_id: Uuid, volume: f32) { + if let Some(node) = self.nodes.iter().find(|n| n.id == node_id) { + for matching_session in self + .sessions + .read() + .iter() + .filter(|session| session_node_match(node, session)) + { + matching_session.set_master_volume(volume); + } + + for matching_device in self + .output_devices + .read() + .iter() + .filter(|device| device.id() == node_id) + { + matching_device.set_master_volume(volume); + } + } + } + + fn application_processes(&self) -> Vec { + let mut added_pids = HashSet::new(); + let mut processes = Vec::new(); + + let my_pid = unsafe { GetCurrentProcessId() }; + + for session in self + .sessions + .read() + .iter() + .filter(|s| s.kind() == AudioSessionKind::Application && s.process_id() != my_pid) + { + if added_pids.insert(session.process_id()) { + processes.push(ProcessInfo { + pid: session.process_id(), + display_name: session.display_name().to_string(), + filename: session.filename().to_string(), + }); + } + } + + processes + } + + fn input_devices(&self) -> Vec { + self.input_devices + .read() + .iter() + .filter(|d| !d.id().is_nil() && d.is_active()) + .map(|d| DeviceInfo { + id: d.id(), + name: d.name().to_string(), + }) + .collect::>() + } + + fn output_devices(&self) -> Vec { + self.output_devices + .read() + .iter() + .filter(|d| !d.id().is_nil() && d.is_active()) + .map(|d| DeviceInfo { + id: d.id(), + name: d.name().to_string(), + }) + .collect::>() + } +} diff --git a/crates/nodio-win32/src/custom.rs b/crates/nodio-win32/src/custom.rs new file mode 100644 index 0000000..f4dd314 --- /dev/null +++ b/crates/nodio-win32/src/custom.rs @@ -0,0 +1,950 @@ +#![allow(non_snake_case)] + +use std::ffi::c_void; +use std::fmt::Debug; +use std::mem; +use std::mem::{ManuallyDrop, MaybeUninit}; +use std::ptr::null_mut; +use std::sync::mpsc::Sender; + +use log::warn; +use widestring::U16CStr; +use windows::core::{IUnknown, IUnknownVtbl, PCWSTR}; +use windows::Win32::Foundation::{BOOL, E_NOINTERFACE, S_OK}; +use windows::Win32::Media::Audio::{ + eCapture, eCommunications, eConsole, eMultimedia, eRender, AudioSessionDisconnectReason, + AudioSessionState, AudioSessionStateActive, AudioSessionStateExpired, + AudioSessionStateInactive, DisconnectReasonDeviceRemoval, + DisconnectReasonExclusiveModeOverride, DisconnectReasonFormatChanged, + DisconnectReasonServerShutdown, DisconnectReasonSessionDisconnected, + DisconnectReasonSessionLogoff, IAudioSessionControl, IAudioSessionControl2, + IAudioSessionEvents, IAudioSessionEvents_Vtbl, IAudioSessionNotification, + IAudioSessionNotification_Vtbl, IMMNotificationClient, IMMNotificationClient_Vtbl, + AUDIO_VOLUME_NOTIFICATION_DATA, DEVICE_STATE_ACTIVE, DEVICE_STATE_DISABLED, + DEVICE_STATE_NOTPRESENT, DEVICE_STATE_UNPLUGGED, +}; +use windows::Win32::System::Registry::{ + GetRegistryValueWithFallbackW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ, +}; +use windows::Win32::System::WinRT::RoGetActivationFactory; +use windows::Win32::UI::Shell::PropertiesSystem::PROPERTYKEY; +use windows::{ + core::{ + IInspectable, IInspectableVtbl, Interface, IntoParam, Param, RawPtr, Result, GUID, HRESULT, + HSTRING, + }, + Win32::Media::Audio::{EDataFlow, ERole}, +}; + +fn os_version() -> u32 { + let mut os_version: [u16; 512] = [0; 512]; + + let status = unsafe { + GetRegistryValueWithFallbackW( + HKEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", + HKEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", + "CurrentBuild", + RRF_RT_REG_SZ.0, + null_mut(), + &mut os_version as *mut _ as _, + 512, + null_mut(), + ) + } + .0; + + if status != 0 { + panic!("Failed to get os version: WIN32_ERROR({})", status); + } + + HSTRING::from_wide( + &os_version + .iter() + .take_while(|&&c| c != 0) + .copied() + .collect::>(), + ) + .to_string_lossy() + .parse::() + .expect("Failed to parse os version") +} + +pub fn create_audio_policy_config() -> Box { + const LATEST_GUID: u128 = 0xab3d4648_e242_459f_b02f_541c70306324; + const LEGACY_GUID: u128 = 0x2a59116d_6c4f_45e0_a74f_707e3fef9258; + + let name = HSTRING::from("Windows.Media.Internal.AudioPolicyConfig"); + + unsafe { + if os_version() >= 21390 { + Box::new(RoGetActivationFactory::<_, IAudioPolicyConfig>(name).unwrap()) + } else { + Box::new(RoGetActivationFactory::<_, IAudioPolicyConfig>(name).unwrap()) + } + } +} + +pub trait AudioPolicyConfig { + unsafe fn persistent_default_audio_endpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + ) -> Result; + + unsafe fn set_persistent_default_audio_endpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + device_id: HSTRING, + ) -> Result<()>; + + unsafe fn clear_all_persisted_default_endpoints(&self) -> Result<()>; +} + +impl AudioPolicyConfig for IAudioPolicyConfig { + unsafe fn persistent_default_audio_endpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + ) -> Result { + self.GetPersistedDefaultAudioEndpoint(process_id, data_flow, role) + } + + unsafe fn set_persistent_default_audio_endpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + device_id: HSTRING, + ) -> Result<()> { + self.SetPersistedDefaultAudioEndpoint(process_id, data_flow, role, device_id) + } + + unsafe fn clear_all_persisted_default_endpoints(&self) -> Result<()> { + self.ClearAllPersistedApplicationDefaultEndpoints() + } +} + +#[repr(transparent)] +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct IAudioPolicyConfig(IInspectable); +impl IAudioPolicyConfig { + pub unsafe fn GetPersistedDefaultAudioEndpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + ) -> Result { + let mut result__ = MaybeUninit::>::zeroed(); + (Interface::vtable(self).GetPersistedDefaultAudioEndpoint)( + mem::transmute_copy(self), + process_id, + data_flow, + role, + result__.as_mut_ptr(), + ) + .from_abi::(result__) + } + + pub unsafe fn SetPersistedDefaultAudioEndpoint( + &self, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + device_id: HSTRING, + ) -> Result<()> { + (Interface::vtable(self).SetPersistedDefaultAudioEndpoint)( + mem::transmute_copy(self), + process_id, + data_flow, + role, + device_id, + ) + .ok() + } + + pub unsafe fn ClearAllPersistedApplicationDefaultEndpoints(&self) -> Result<()> { + (Interface::vtable(self).ClearAllPersistedApplicationDefaultEndpoints)(mem::transmute_copy( + self, + )) + .ok() + } +} +impl From> for IInspectable { + fn from(value: IAudioPolicyConfig) -> Self { + unsafe { mem::transmute(value) } + } +} + +impl From<&IAudioPolicyConfig> for IInspectable { + fn from(value: &IAudioPolicyConfig) -> Self { + From::from(Clone::clone(value)) + } +} + +impl<'a, const T: u128> IntoParam<'a, IInspectable> for IAudioPolicyConfig { + fn into_param(self) -> Param<'a, IInspectable> { + Param::Owned(unsafe { mem::transmute(self) }) + } +} + +impl<'a, const T: u128> IntoParam<'a, IInspectable> for &IAudioPolicyConfig { + fn into_param(self) -> Param<'a, IInspectable> { + Param::Borrowed(unsafe { mem::transmute(self) }) + } +} + +unsafe impl Interface for IAudioPolicyConfig { + const IID: GUID = GUID::from_u128(T); + type Vtable = IAudioPolicyConfig_Vtbl; +} + +#[repr(C)] +#[doc(hidden)] +pub struct IAudioPolicyConfig_Vtbl { + pub base: IInspectableVtbl, + + pub __incomplete__add_CtxVolumeChanged: unsafe extern "system" fn() -> u32, + pub __incomplete__remove_CtxVolumeChanged: unsafe extern "system" fn() -> u32, + pub __incomplete__add_RingerVibrateStateChanged: unsafe extern "system" fn() -> u32, + pub __incomplete__remove_RingerVibrateStateChanged: unsafe extern "system" fn() -> u32, + pub __incomplete__SetVolumeGroupGainForId: unsafe extern "system" fn() -> u32, + pub __incomplete__GetVolumeGroupGainForId: unsafe extern "system" fn() -> u32, + pub __incomplete__GetActiveVolumeGroupForEndpointId: unsafe extern "system" fn() -> u32, + pub __incomplete__GetVolumeGroupsForEndpoint: unsafe extern "system" fn() -> u32, + pub __incomplete__GetCurrentVolumeContext: unsafe extern "system" fn() -> u32, + pub __incomplete__SetVolumeGroupMuteForId: unsafe extern "system" fn() -> u32, + pub __incomplete__GetVolumeGroupMuteForId: unsafe extern "system" fn() -> u32, + pub __incomplete__SetRingerVibrateState: unsafe extern "system" fn() -> u32, + pub __incomplete__GetRingerVibrateState: unsafe extern "system" fn() -> u32, + pub __incomplete__SetPreferredChatApplication: unsafe extern "system" fn() -> u32, + pub __incomplete__ResetPreferredChatApplication: unsafe extern "system" fn() -> u32, + pub __incomplete__GetPreferredChatApplication: unsafe extern "system" fn() -> u32, + pub __incomplete__GetCurrentChatApplications: unsafe extern "system" fn() -> u32, + pub __incomplete__add_ChatContextChanged: unsafe extern "system" fn() -> u32, + pub __incomplete__remove_ChatContextChanged: unsafe extern "system" fn() -> u32, + + pub SetPersistedDefaultAudioEndpoint: unsafe extern "system" fn( + this: *mut c_void, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + device_id: HSTRING, + ) -> HRESULT, + + pub GetPersistedDefaultAudioEndpoint: unsafe extern "system" fn( + this: *mut c_void, + process_id: u32, + data_flow: EDataFlow, + role: ERole, + device_id_ptr: *mut ManuallyDrop, + ) -> HRESULT, + + pub ClearAllPersistedApplicationDefaultEndpoints: + unsafe extern "system" fn(this: *mut c_void) -> HRESULT, +} + +/// Direction in which audio is moving. +#[derive(Debug)] +pub enum FlowDirection { + /// Audio is being rendered (played). + Render, + /// Audio is being captured. + Capture, +} + +/// Audio device role. +#[derive(Debug)] +pub enum Role { + /// Interaction with the computer. + Console, + /// Playing or recording audio content. + Multimedia, + /// Voice communications with another person. + Communications, +} + +/// State of the device. +#[derive(Debug, Ord, PartialOrd, Eq, PartialEq)] +pub enum DeviceState { + /// The audio endpoint device is active. That is, the audio adapter that + /// connects to the endpoint device is present and enabled. In addition, if + /// the endpoint device plugs into a jack on the adapter, then the endpoint + /// device is plugged in. + Active, + /// The audio endpoint device is disabled. The user has disabled the device + /// in the Windows multimedia control panel. + Disabled, + /// The audio endpoint device is not present because the audio adapter that + /// connects to the endpoint device has been removed from the system, or the + /// user has disabled the adapter device in Device Manager. + NotPresent, + /// The audio endpoint device is unplugged. The audio adapter that contains + /// the jack for the endpoint device is present and enabled, but the + /// endpoint device is not plugged into the jack. Only a device with + /// jack-presence detection can be in this state. + Unplugged, +} + +/// A notification about a device change. +#[derive(Debug)] +pub enum DeviceNotification { + /// The default device has changed. + DefaultDeviceChanged { + /// The flow of the device. + flow_direction: FlowDirection, + /// The role of the device. + role: Role, + /// The device ID. + default_device_id: String, + }, + /// A device was added. + DeviceAdded { + /// The device ID. + device_id: String, + }, + /// A device was removed. + DeviceRemoved { + /// The device ID. + device_id: String, + }, + /// The state of a device changed. + StateChanged { + /// The device ID. + device_id: String, + /// The new device state. + state: DeviceState, + }, + /// A property changed on the device. + PropertyChanged { + /// The device ID. + device_id: String, + /// The property fmtid. + property_key_fmtid: GUID, + /// The property pid. + property_key_pid: u32, + }, +} + +#[repr(C)] +pub(crate) struct DeviceNotifications { + _abi: Box, + ref_cnt: u32, + tx: Sender, +} + +impl DeviceNotifications { + #[allow(clippy::new_ret_no_self)] + pub(crate) fn new(tx: Sender) -> IMMNotificationClient { + let target = Box::new(Self { + _abi: Box::new(IMMNotificationClient_Vtbl { + base__: IUnknownVtbl { + QueryInterface: Self::_query_interface, + AddRef: Self::_add_ref, + Release: Self::_release, + }, + OnDeviceStateChanged: Self::_on_device_state_changed, + OnDeviceAdded: Self::_on_device_added, + OnDeviceRemoved: Self::_on_device_removed, + OnDefaultDeviceChanged: Self::_on_default_device_changed, + OnPropertyValueChanged: Self::_on_property_value_changed, + }), + ref_cnt: 1, + tx, + }); + + unsafe { + let ptr = Box::into_raw(target); + mem::transmute(ptr) + } + } + + fn query_interface(&mut self, iid: &GUID, interface: *mut *const c_void) -> HRESULT { + if iid == &IAudioSessionEvents::IID || iid == &IUnknown::IID { + unsafe { + *interface = self as *mut Self as *mut _; + } + + self.add_ref(); + + S_OK + } else { + E_NOINTERFACE + } + } + + fn add_ref(&mut self) -> u32 { + self.ref_cnt += 1; + self.ref_cnt + } + + fn release(&mut self) -> u32 { + self.ref_cnt -= 1; + + if self.ref_cnt == 0 { + unsafe { + Box::from_raw(self as *mut Self); + } + } + + self.ref_cnt + } + + fn on_default_device_changed( + &mut self, + flow_direction: FlowDirection, + role: Role, + default_device_id: String, + ) { + self.tx + .send(DeviceNotification::DefaultDeviceChanged { + flow_direction, + role, + default_device_id, + }) + .expect("could not send on_default_device_changed"); + } + + fn on_device_added(&mut self, device_id: String) { + self.tx + .send(DeviceNotification::DeviceAdded { device_id }) + .expect("could not send on_device_added"); + } + + fn on_device_removed(&mut self, device_id: String) { + self.tx + .send(DeviceNotification::DeviceRemoved { device_id }) + .expect("could not send on_device_removed"); + } + + fn on_device_state_changed(&mut self, device_id: String, new_state: DeviceState) { + self.tx + .send(DeviceNotification::StateChanged { + device_id, + state: new_state, + }) + .expect("could not send on_device_state_changed"); + } + + fn on_property_value_changed(&mut self, device_id: String, property_key: PROPERTYKEY) { + self.tx + .send(DeviceNotification::PropertyChanged { + device_id, + property_key_fmtid: property_key.fmtid, + property_key_pid: property_key.pid, + }) + .expect("could not send on_property_value_changed"); + } +} + +impl DeviceNotifications { + unsafe extern "system" fn _query_interface( + this: RawPtr, + iid: &GUID, + interface: *mut *const c_void, + ) -> HRESULT { + (*(this as *mut Self)).query_interface(iid, interface) + } + + unsafe extern "system" fn _add_ref(this: RawPtr) -> u32 { + (*(this as *mut Self)).add_ref() + } + + unsafe extern "system" fn _release(this: RawPtr) -> u32 { + (*(this as *mut Self)).release() + } + + unsafe extern "system" fn _on_default_device_changed( + this: RawPtr, + flow: EDataFlow, + role: ERole, + default_device_id: PCWSTR, + ) -> HRESULT { + let default_device_id = U16CStr::from_ptr_str(default_device_id.0).to_string_lossy(); + + #[allow(non_upper_case_globals)] + let flow = match flow { + eRender => FlowDirection::Render, + eCapture => FlowDirection::Capture, + _ => { + warn!("got unknown flow direction {:?}", flow); + return S_OK; + } + }; + + #[allow(non_upper_case_globals)] + let role = match role { + eConsole => Role::Console, + eMultimedia => Role::Multimedia, + eCommunications => Role::Communications, + _ => { + warn!("got unknown role {:?}", role); + return S_OK; + } + }; + + (*(this as *mut Self)).on_default_device_changed(flow, role, default_device_id); + + S_OK + } + + unsafe extern "system" fn _on_device_added(this: RawPtr, device_id: PCWSTR) -> HRESULT { + let device_id = U16CStr::from_ptr_str(device_id.0).to_string_lossy(); + + (*(this as *mut Self)).on_device_added(device_id); + + S_OK + } + + unsafe extern "system" fn _on_device_removed(this: RawPtr, device_id: PCWSTR) -> HRESULT { + let device_id = U16CStr::from_ptr_str(device_id.0).to_string_lossy(); + + (*(this as *mut Self)).on_device_removed(device_id); + + S_OK + } + + unsafe extern "system" fn _on_device_state_changed( + this: RawPtr, + device_id: PCWSTR, + new_state: u32, + ) -> HRESULT { + let device_id = U16CStr::from_ptr_str(device_id.0).to_string_lossy(); + + let new_state = match new_state { + DEVICE_STATE_ACTIVE => DeviceState::Active, + DEVICE_STATE_DISABLED => DeviceState::Disabled, + DEVICE_STATE_NOTPRESENT => DeviceState::NotPresent, + DEVICE_STATE_UNPLUGGED => DeviceState::Unplugged, + _ => { + warn!("got unknown device state: {:?}", new_state); + return S_OK; + } + }; + + (*(this as *mut Self)).on_device_state_changed(device_id, new_state); + + S_OK + } + + unsafe extern "system" fn _on_property_value_changed( + this: RawPtr, + device_id: PCWSTR, + property_key: PROPERTYKEY, + ) -> HRESULT { + let device_id = U16CStr::from_ptr_str(device_id.0).to_string_lossy(); + + (*(this as *mut Self)).on_property_value_changed(device_id, property_key); + + S_OK + } +} + +/// An event for a device. +#[derive(Debug)] +pub struct DeviceEvent { + /// The new volume level, [0, 1]. + pub level: f32, + /// If the device is muted. + pub muted: bool, + + /// The volume for each channel. + pub channel_volumes: Vec, + + /// An event context, if one exists. + pub event_context: GUID, +} + +impl From for DeviceEvent { + fn from(notification_data: AUDIO_VOLUME_NOTIFICATION_DATA) -> Self { + let channel_volumes = unsafe { + std::slice::from_raw_parts( + ¬ification_data.afChannelVolumes as *const _, + notification_data.nChannels as usize, + ) + }; + + Self { + level: notification_data.fMasterVolume, + muted: notification_data.bMuted.into(), + + channel_volumes: channel_volumes.to_vec(), + + event_context: notification_data.guidEventContext, + } + } +} + +/// A notification about an audio session. +#[derive(Debug)] +pub struct AudioSessionNotification { + /// The session identifier. + pub session_identifier: String, + /// The session instance identifier. + pub session_instance_identifier: String, +} + +#[repr(C)] +pub(crate) struct AudioSessionNotifications { + _abi: Box, + ref_cnt: u32, + tx: Sender, +} + +impl AudioSessionNotifications { + #[allow(clippy::new_ret_no_self)] + pub(crate) fn new(tx: Sender) -> IAudioSessionNotification { + let target = Box::new(Self { + _abi: Box::new(IAudioSessionNotification_Vtbl { + base__: IUnknownVtbl { + QueryInterface: Self::_query_interface, + AddRef: Self::_add_ref, + Release: Self::_release, + }, + OnSessionCreated: Self::_on_session_created, + }), + ref_cnt: 1, + tx, + }); + + unsafe { + let ptr = Box::into_raw(target); + mem::transmute(ptr) + } + } + + fn query_interface(&mut self, iid: &GUID, interface: *mut *const c_void) -> HRESULT { + if iid == &IAudioSessionEvents::IID || iid == &IUnknown::IID { + unsafe { + *interface = self as *mut Self as *mut _; + } + + self.add_ref(); + + S_OK + } else { + E_NOINTERFACE + } + } + + fn add_ref(&mut self) -> u32 { + self.ref_cnt += 1; + self.ref_cnt + } + + fn release(&mut self) -> u32 { + self.ref_cnt -= 1; + + if self.ref_cnt == 0 { + unsafe { + Box::from_raw(self as *mut Self); + } + } + + self.ref_cnt + } + + fn on_session_created(&mut self, new_session: IAudioSessionControl2) { + let (session_identifier, session_instance_identifier) = unsafe { + let session_identifier = new_session.GetSessionIdentifier().unwrap_or_default(); + let session_identifier = U16CStr::from_ptr_str(session_identifier.0).to_string_lossy(); + + let session_instance_identifier = new_session + .GetSessionInstanceIdentifier() + .unwrap_or_default(); + let session_instance_identifier = + U16CStr::from_ptr_str(session_instance_identifier.0).to_string_lossy(); + + (session_identifier, session_instance_identifier) + }; + + self.tx + .send(AudioSessionNotification { + session_identifier, + session_instance_identifier, + }) + .expect("could not send on_session_created"); + } +} + +impl AudioSessionNotifications { + unsafe extern "system" fn _query_interface( + this: RawPtr, + iid: &GUID, + interface: *mut *const c_void, + ) -> HRESULT { + (*(this as *mut Self)).query_interface(iid, interface) + } + + unsafe extern "system" fn _add_ref(this: RawPtr) -> u32 { + (*(this as *mut Self)).add_ref() + } + + unsafe extern "system" fn _release(this: RawPtr) -> u32 { + (*(this as *mut Self)).release() + } + + unsafe extern "system" fn _on_session_created(this: RawPtr, new_session: RawPtr) -> HRESULT { + struct ComObject(RawPtr); + let obj = ComObject(new_session); + let sess = (&*(&obj as *const _ as *const IAudioSessionControl)).clone(); + + let new_session = if let Ok(control) = sess.cast() { + control + } else { + warn!("could not cast NewSession to IAudioSessionControl2"); + return S_OK; + }; + + (*(this as *mut Self)).on_session_created(new_session); + + S_OK + } +} + +/// The type of change for an audio session. +#[derive(Debug)] +pub enum AudioSessionEvent { + /// The volume or mute status has changed. + VolumeChange { + /// The new volume level, [0, 1]. + level: f32, + /// If the session is muted. + muted: bool, + }, + /// The state of the session has changed. + StateChange(SessionState), + /// The session has disconnected. + Disconnect(SessionDisconnect), +} + +/// An audio session state. +#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] +pub enum SessionState { + /// The audio session is currently active. + Active, + /// The audio session has become inactive. + Inactive, + /// The audio session has expired and is no longer valid. + Expired, +} + +/// The reason why an audio session disconnected. +#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +pub enum SessionDisconnect { + /// The user removed the audio endpoint device. + DeviceRemoved, + /// The Windows audio service has stopped. + ServerShutdown, + /// The stream format changed for the device that the audio session is + /// connected to. + FormatChanged, + /// The user logged off the Windows Terminal Services (WTS) session that the + /// audio session was running in. + SessionLogoff, + /// The WTS session that the audio session was running in was disconnected. + SessionDisconnected, + /// The (shared-mode) audio session was disconnected to make the audio + /// endpoint device available for an exclusive-mode connection. + ExclusiveModeOverride, +} + +#[repr(C)] +pub(crate) struct AudioSessionEvents { + _abi: Box, + ref_cnt: u32, + + tx: Sender, +} + +impl AudioSessionEvents { + pub(crate) fn create(tx: Sender) -> IAudioSessionEvents { + let target = Box::new(Self { + _abi: Box::new(IAudioSessionEvents_Vtbl { + base__: IUnknownVtbl { + QueryInterface: Self::_query_interface, + AddRef: Self::_add_ref, + Release: Self::_release, + }, + OnDisplayNameChanged: Self::_on_display_name_changed, + OnIconPathChanged: Self::_on_icon_path_changed, + OnSimpleVolumeChanged: Self::_on_simple_volume_changed, + OnChannelVolumeChanged: Self::_on_channel_volume_changed, + OnGroupingParamChanged: Self::_on_grouping_param_changed, + OnStateChanged: Self::_on_state_changed, + OnSessionDisconnected: Self::_on_session_disconnected, + }), + ref_cnt: 1, + tx, + }); + + unsafe { + let ptr = Box::into_raw(target); + mem::transmute(ptr) + } + } + + fn query_interface(&mut self, iid: &GUID, interface: *mut *const c_void) -> HRESULT { + if iid == &IAudioSessionEvents::IID || iid == &IUnknown::IID { + unsafe { + *interface = self as *mut Self as *mut _; + } + + self.add_ref(); + + S_OK + } else { + E_NOINTERFACE + } + } + + fn add_ref(&mut self) -> u32 { + self.ref_cnt += 1; + self.ref_cnt + } + + fn release(&mut self) -> u32 { + self.ref_cnt -= 1; + + if self.ref_cnt == 0 { + unsafe { + Box::from_raw(self as *mut Self); + } + } + + self.ref_cnt + } + + fn simple_volume_changed(&mut self, new_volume: f32, new_mute: bool) { + self.tx + .send(AudioSessionEvent::VolumeChange { + level: new_volume, + muted: new_mute, + }) + .expect("could not send simple_volume_changed"); + } + + fn on_state_changed(&mut self, state: SessionState) { + self.tx + .send(AudioSessionEvent::StateChange(state)) + .expect("could not send on_state_changed"); + } + + fn on_session_disconnected(&mut self, session_disconnect: SessionDisconnect) { + self.tx + .send(AudioSessionEvent::Disconnect(session_disconnect)) + .expect("could not send on_session_disconnected"); + } +} + +/// Methods called by Windows API. +impl AudioSessionEvents { + unsafe extern "system" fn _query_interface( + this: RawPtr, + iid: &GUID, + interface: *mut *const c_void, + ) -> HRESULT { + (*(this as *mut Self)).query_interface(iid, interface) + } + + unsafe extern "system" fn _add_ref(this: RawPtr) -> u32 { + (*(this as *mut Self)).add_ref() + } + + unsafe extern "system" fn _release(this: RawPtr) -> u32 { + (*(this as *mut Self)).release() + } + + unsafe extern "system" fn _on_display_name_changed( + _this: RawPtr, + _new_display_name: PCWSTR, + _event_context: *const GUID, + ) -> HRESULT { + S_OK + } + + unsafe extern "system" fn _on_icon_path_changed( + _this: RawPtr, + _new_icon_path: PCWSTR, + _event_context: *const GUID, + ) -> HRESULT { + S_OK + } + + unsafe extern "system" fn _on_simple_volume_changed( + this: RawPtr, + new_volume: f32, + new_mute: BOOL, + _event_context: *const GUID, + ) -> HRESULT { + (*(this as *mut Self)).simple_volume_changed(new_volume, new_mute.into()); + + S_OK + } + + unsafe extern "system" fn _on_channel_volume_changed( + _this: RawPtr, + _channel_count: u32, + _new_channel_volume_array: *const f32, + _changed_channel: u32, + _event_context: *const GUID, + ) -> HRESULT { + S_OK + } + + unsafe extern "system" fn _on_grouping_param_changed( + _this: RawPtr, + _new_grouping_param: *const GUID, + _event_context: *const GUID, + ) -> HRESULT { + S_OK + } + + unsafe extern "system" fn _on_state_changed( + this: RawPtr, + new_state: AudioSessionState, + ) -> HRESULT { + #[allow(non_upper_case_globals)] + let state = match new_state { + AudioSessionStateActive => SessionState::Active, + AudioSessionStateInactive => SessionState::Inactive, + AudioSessionStateExpired => SessionState::Expired, + _ => { + warn!("got unknown state"); + return S_OK; + } + }; + + (*(this as *mut Self)).on_state_changed(state); + + S_OK + } + + unsafe extern "system" fn _on_session_disconnected( + this: RawPtr, + disconnect_reason: AudioSessionDisconnectReason, + ) -> HRESULT { + #[allow(non_upper_case_globals)] + let session_disconnect = match disconnect_reason { + DisconnectReasonDeviceRemoval => SessionDisconnect::DeviceRemoved, + DisconnectReasonServerShutdown => SessionDisconnect::ServerShutdown, + DisconnectReasonFormatChanged => SessionDisconnect::FormatChanged, + DisconnectReasonSessionLogoff => SessionDisconnect::SessionLogoff, + DisconnectReasonSessionDisconnected => SessionDisconnect::SessionDisconnected, + DisconnectReasonExclusiveModeOverride => SessionDisconnect::ExclusiveModeOverride, + _ => { + warn!("got unknown disconnect reason"); + return S_OK; + } + }; + + (*(this as *mut Self)).on_session_disconnected(session_disconnect); + + S_OK + } +} diff --git a/crates/nodio-win32/src/device.rs b/crates/nodio-win32/src/device.rs new file mode 100644 index 0000000..276665f --- /dev/null +++ b/crates/nodio-win32/src/device.rs @@ -0,0 +1,335 @@ +use std::mem::MaybeUninit; +use std::ptr::null; +use std::str::FromStr; +use std::sync::mpsc::channel; +use std::sync::Arc; +use std::time::Duration; + +use log::{error, trace, warn}; +use notify_thread::JoinHandle; +use parking_lot::Mutex; +use widestring::U16Str; +use windows::core::{Interface, GUID, HSTRING, PWSTR}; +use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; +use windows::Win32::Media::Audio as windows_audio; +use windows::Win32::Media::Audio::Endpoints::{IAudioEndpointVolume, IAudioMeterInformation}; +use windows::Win32::Media::Audio::{ + EDataFlow, IAudioSessionControl, IAudioSessionEnumerator, IAudioSessionManager2, + IAudioSessionNotification, IMMDevice, +}; +use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, STGM_READ, STGM_WRITE}; +use windows::Win32::System::Com::CLSCTX_ALL; +use windows::Win32::System::Ole::{VT_BOOL, VT_LPWSTR}; +use windows::Win32::UI::Shell::PropertiesSystem::{IPropertyStore, PropVariantToBSTR, PROPERTYKEY}; + +use nodio_core::Uuid; + +use crate::custom::{AudioSessionNotification, AudioSessionNotifications, DeviceState}; +use crate::session::AudioSession; +use crate::{pwstr_to_string, Callback}; + +pub const DEVINTERFACE_AUDIO_RENDER: &str = "{e6327cad-dcec-4949-ae8a-991e976a79d2}"; +pub const DEVINTERFACE_AUDIO_CAPTURE: &str = "{2eef81be-33fa-4800-9670-1cd474972c3f}"; +pub const MMDEVAPI_TOKEN: &str = r#"\\?\SWD#MMDEVAPI#{0.0.0.00000000}."#; + +pub struct AudioDevice { + mmdevice: IMMDevice, + + audio_session_manager: IAudioSessionManager2, + session_notifications: IAudioSessionNotification, + endpoint_volume: Option, + meter: Option, + name: String, + + id: Uuid, + + session_notification_callback: Arc>>>, + + session_notification_thread: Option>, +} + +impl Drop for AudioDevice { + fn drop(&mut self) { + trace!("dropping audio device {}", self.name); + unsafe { + self.audio_session_manager + .UnregisterSessionNotification(self.session_notifications.clone()) + .ok(); + } + if let Some(t) = self.session_notification_thread.take() { + t.notify(); + } + trace!("audio device dropped"); + } +} + +impl AudioDevice { + pub fn new(mmdevice: IMMDevice) -> windows::core::Result { + unsafe { + let audio_session_manager = mmdevice.activate::()?; + + let (session_notification_tx, session_notification_rx) = channel(); + let session_notifications = AudioSessionNotifications::new(session_notification_tx); + audio_session_manager + .RegisterSessionNotification(session_notifications.clone()) + .unwrap(); + + let session_notification_callback: Arc< + Mutex>>, + > = Arc::new(Mutex::new(None)); + + let session_notification_thread = { + let session_notification_callback = session_notification_callback.clone(); + notify_thread::spawn(move |thread| loop { + match session_notification_rx.recv_timeout(Duration::from_millis(100)) { + Ok(event) => { + trace!("Device session event: {:?}", event); + + if let Some(cb) = session_notification_callback.lock().as_ref() { + cb(event); + } + } + _ if thread.notified() => { + trace!("Session notification thread ended"); + return; + } + _ => {} + } + }) + }; + + let properties: IPropertyStore = mmdevice.OpenPropertyStore(STGM_READ)?; + let name: PROPVARIANT = properties.GetValue(&PKEY_Device_FriendlyName)?; + let name = U16Str::from_slice(PropVariantToBSTR(&name)?.as_wide()).to_string_lossy(); + + let id = mmdevice.GetId().map(|id| { + if id.is_null() { + Uuid::nil() + } else { + pwstr_to_string(id) + .split_once("}.{") + .and_then(|(_, s)| s.split('}').next()) + .and_then(|s| Uuid::from_str(s).ok()) + .unwrap_or_else(Uuid::nil) + } + })?; + + let endpoint_volume: Option = mmdevice + .GetState() + .ok() + .filter(|state| *state == windows_audio::DEVICE_STATE_ACTIVE) + .and_then(|_| mmdevice.activate().ok()); + + let meter: Option = mmdevice + .GetState() + .ok() + .filter(|state| *state == windows_audio::DEVICE_STATE_ACTIVE) + .and_then(|_| mmdevice.activate().ok()); + + Ok(Self { + mmdevice, + audio_session_manager, + endpoint_volume, + session_notifications, + name, + id, + session_notification_callback, + session_notification_thread: Some(session_notification_thread), + meter, + }) + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn mmdevice(&self) -> &IMMDevice { + &self.mmdevice + } + + pub fn set_session_notification_callback(&mut self, cb: T) + where + T: Fn(AudioSessionNotification) + Send + Sync + 'static, + { + let _ = self + .session_notification_callback + .lock() + .insert(Box::new(cb)); + } + + pub fn set_listen(&self, target: Option<&AudioDevice>) -> windows::core::Result<()> { + unsafe { + let listen_state_prop_key = PROPERTYKEY { + fmtid: GUID::from_u128(0x24DBB0FC_9311_4B3D_9CF0_18FF155639D4), + pid: 1u32, + }; + + let listen_target_prop_key = PROPERTYKEY { + fmtid: GUID::from_u128(0x24DBB0FC_9311_4B3D_9CF0_18FF155639D4), + pid: 0u32, + }; + + let properties: IPropertyStore = self.mmdevice.OpenPropertyStore(STGM_WRITE)?; + + let mut listen_state_prop_value: PROPVARIANT = PROPVARIANT::default(); + (*(listen_state_prop_value.Anonymous.Anonymous)).vt = VT_BOOL.0 as _; + (*(listen_state_prop_value.Anonymous.Anonymous)) + .Anonymous + .boolVal = if target.is_some() { -1 } else { 0 }; + + if let Some(target) = target { + let mut target_device_id = + HSTRING::from(format!("{}.{{{}}}", "{0.0.0.00000000}", target.id())) + .as_wide() + .to_vec(); + let mut listen_target_prop_value: PROPVARIANT = PROPVARIANT::default(); + (*(listen_target_prop_value.Anonymous.Anonymous)).vt = VT_LPWSTR.0 as _; + (*(listen_target_prop_value.Anonymous.Anonymous)) + .Anonymous + .pwszVal = PWSTR(target_device_id.as_mut_ptr()); + + properties.SetValue(&listen_target_prop_key, &listen_target_prop_value)?; + } + + properties.SetValue(&listen_state_prop_key, &listen_state_prop_value)?; + + properties.Commit()?; + } + + Ok(()) + } + + pub fn enumerate_sessions(&self) -> windows::core::Result> { + unsafe { + let session_enumerator: IAudioSessionEnumerator = self + .audio_session_manager + .GetSessionEnumerator() + .map_err(|err| { + error!("Failed to get session enumerator: {:?}", err); + err + })?; + + let session_count = session_enumerator.GetCount().map_err(|err| { + error!("Failed to get session count: {:?}", err); + err + })?; + + let mut sessions = Vec::with_capacity(session_count as usize); + + for i in 0..session_count { + let control: IAudioSessionControl = match session_enumerator.GetSession(i) { + Ok(control) => control, + Err(err) => { + error!("Failed to get session control for session {}: {:?}", i, err); + continue; + } + }; + + let session = match AudioSession::new(control) { + Ok(session) => session, + Err(err) => { + error!("Failed to create session {}: {:?}", i, err); + continue; + } + }; + + if session.process_id() == 0 { + continue; + } + + sessions.push(session); + } + + Ok(sessions) + } + } + + pub fn is_active(&self) -> bool { + self.state() == DeviceState::Active + } + + pub fn state(&self) -> DeviceState { + match unsafe { self.mmdevice.GetState() }.unwrap_or(windows_audio::DEVICE_STATE_DISABLED) { + windows_audio::DEVICE_STATE_ACTIVE => DeviceState::Active, + windows_audio::DEVICE_STATE_DISABLED => DeviceState::Disabled, + windows_audio::DEVICE_STATE_NOTPRESENT => DeviceState::NotPresent, + windows_audio::DEVICE_STATE_UNPLUGGED => DeviceState::Unplugged, + _ => DeviceState::Disabled, + } + } + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn set_master_volume(&self, volume: f32) { + unsafe { + if let Some(endpoint_volume) = self.endpoint_volume.as_ref() { + if let Err(err) = endpoint_volume.SetMasterVolumeLevelScalar(volume, null()) { + warn!("Failed to get audio endpoint volume: {}", err); + } + } + } + } + pub fn master_volume(&self) -> f32 { + unsafe { + self.endpoint_volume + .as_ref() + .and_then(|endpoint_volume| endpoint_volume.GetMasterVolumeLevelScalar().ok()) + .unwrap_or(0.0) + } + } + + pub fn peak_values(&self) -> windows::core::Result<(f32, f32)> { + let meter = match self.meter.as_ref() { + Some(meter) => meter, + None => return Ok((0.0, 0.0)), + }; + + unsafe { + let channel_count = usize::min(2, meter.GetMeteringChannelCount()? as usize); + + let mut values = [0.0; 2]; + meter.GetChannelsPeakValues(&mut values)?; + + if channel_count == 1 { + Ok((values[0], values[0])) + } else { + Ok((values[0], values[1])) + } + } + } + + pub fn mmdevice_id(&self, data_flow: EDataFlow) -> HSTRING { + if self.id.is_nil() { + HSTRING::new() + } else { + HSTRING::from(format!( + r#"{}{{{}}}#{}"#, + MMDEVAPI_TOKEN, + self.id, + match data_flow { + windows_audio::eCapture => DEVINTERFACE_AUDIO_CAPTURE, + _ => DEVINTERFACE_AUDIO_RENDER, + } + )) + } + } +} + +pub trait MMDeviceExt { + fn activate(&self) -> windows::core::Result; +} + +impl MMDeviceExt for IMMDevice { + fn activate(&self) -> windows::core::Result { + unsafe { + let mut result = MaybeUninit::::uninit(); + + self.Activate(&T::IID, CLSCTX_ALL, null(), result.as_mut_ptr() as _)?; + + Ok(result.assume_init()) + } + } +} diff --git a/crates/nodio-win32/src/enumerator.rs b/crates/nodio-win32/src/enumerator.rs new file mode 100644 index 0000000..4b846c7 --- /dev/null +++ b/crates/nodio-win32/src/enumerator.rs @@ -0,0 +1,88 @@ +use crate::com::ensure_com_initialized; +use crate::custom::{DeviceNotification, DeviceNotifications}; +use crate::device::AudioDevice; +use log::{trace, warn}; +use std::sync::mpsc::channel; +use std::thread; +use windows::Win32::Media::Audio::{ + EDataFlow, ERole, IMMDeviceCollection, IMMDeviceEnumerator, IMMNotificationClient, + MMDeviceEnumerator, +}; +use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; + +pub struct AudioDeviceEnumerator { + enumerator: IMMDeviceEnumerator, + _device_notifications: IMMNotificationClient, +} + +impl AudioDeviceEnumerator { + pub fn create() -> windows::core::Result { + ensure_com_initialized(); + + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + + let (device_notification_tx, device_notification_rx) = channel::(); + let device_notifications = DeviceNotifications::new(device_notification_tx); + + enumerator + .RegisterEndpointNotificationCallback(device_notifications.clone()) + .expect("Failed to register endpoint notification callback"); + + thread::spawn(move || { + while let Ok(event) = device_notification_rx.recv() { + trace!("Device event: {:?}", event); + + match event { + DeviceNotification::DefaultDeviceChanged { .. } => {} + DeviceNotification::DeviceAdded { .. } => {} + DeviceNotification::DeviceRemoved { .. } => {} + DeviceNotification::StateChanged { .. } => {} + DeviceNotification::PropertyChanged { .. } => {} + } + } + }); + + Ok(Self { + enumerator, + _device_notifications: device_notifications, + }) + } + } + + pub fn _default_audio_endpoint( + &self, + data_flow: EDataFlow, + role: ERole, + ) -> windows::core::Result { + unsafe { + self.enumerator + .GetDefaultAudioEndpoint(data_flow, role) + .and_then(AudioDevice::new) + } + } + + pub fn enumerate_audio_endpoints( + &self, + data_flow: EDataFlow, + state_mask: u32, + ) -> windows::core::Result> { + unsafe { + let collection: IMMDeviceCollection = + self.enumerator.EnumAudioEndpoints(data_flow, state_mask)?; + + let count = collection.GetCount()?; + let mut endpoints = Vec::with_capacity(count as usize); + + for i in 0..count { + match collection.Item(i).and_then(AudioDevice::new) { + Ok(device) => endpoints.push(device), + Err(err) => warn!("Could not get audio endpoint: {:?}", err), + } + } + + Ok(endpoints) + } + } +} diff --git a/crates/nodio-win32/src/lib.rs b/crates/nodio-win32/src/lib.rs new file mode 100644 index 0000000..698e3b3 --- /dev/null +++ b/crates/nodio-win32/src/lib.rs @@ -0,0 +1,25 @@ +#![deny(clippy::all)] +mod com; +mod context; +mod custom; +mod device; +mod enumerator; +mod loopback; +mod node; +mod render; +mod session; + +use widestring::U16CStr; +use windows::core::PWSTR; + +pub use context::Win32Context; + +fn pwstr_to_string(pwstr: PWSTR) -> String { + if pwstr.is_null() { + String::default() + } else { + unsafe { U16CStr::from_ptr_str(pwstr.0).to_string_lossy() } + } +} + +type Callback = Box; diff --git a/crates/nodio-win32/src/loopback.rs b/crates/nodio-win32/src/loopback.rs new file mode 100644 index 0000000..5face05 --- /dev/null +++ b/crates/nodio-win32/src/loopback.rs @@ -0,0 +1,412 @@ +use parking_lot::Mutex; +use std::future::Future; +use std::mem::ManuallyDrop; +use std::pin::Pin; +use std::ptr::{null, null_mut}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Receiver; +use std::sync::{mpsc, Arc}; +use std::task::{Context, Poll, Waker}; + +use crate::render::RenderClient; +use nodio_core::Uuid; +use pollster::FutureExt as _; +use windows::core::{implement, IUnknown, Interface, Result, GUID, HRESULT}; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Media::Audio::{ + ActivateAudioInterfaceAsync, IActivateAudioInterfaceAsyncOperation, + IActivateAudioInterfaceCompletionHandler, IActivateAudioInterfaceCompletionHandler_Impl, + IMMDevice, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + AUDCLNT_STREAMFLAGS_LOOPBACK, AUDIOCLIENT_ACTIVATION_PARAMS, AUDIOCLIENT_ACTIVATION_PARAMS_0, + AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS, + PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, + PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE, VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, + WAVEFORMATEXTENSIBLE, +}; +use windows::Win32::System::Com::StructuredStorage::{ + PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, +}; +use windows::Win32::System::Com::BLOB; +use windows::Win32::System::Ole::VT_BLOB; +use windows::Win32::System::Threading::CreateEventW; +use windows::Win32::{ + Media::Audio::{IAudioCaptureClient, IAudioClient, AUDCLNT_SHAREMODE_SHARED}, + Media::MediaFoundation::*, + System::Com::StructuredStorage::PROPVARIANT, +}; + +pub struct LoopbackCapture { + target_pid: u32, + include_process_tree: bool, + + format: WAVEFORMATEXTENSIBLE, + + sample_ready_key: u64, + audio_client: Option, + capture_client: Option, + ev_sample_ready: HANDLE, + sample_ready_result: Option, + + queue_id: u32, +} + +impl LoopbackCapture { + fn new(target_pid: u32, format: WAVEFORMATEXTENSIBLE) -> Self { + Self { + format, + target_pid, + include_process_tree: true, + sample_ready_key: 0, + audio_client: None, + capture_client: None, + ev_sample_ready: HANDLE(0), + sample_ready_result: None, + queue_id: 0, + } + } + + pub unsafe fn get_next_packet_size(&self) -> Result { + self.capture_client.as_ref().unwrap().GetNextPacketSize() + } + + pub unsafe fn get_buffer(&mut self) -> Result { + let mut data_ptr = null_mut::(); + + let mut frames: u32 = 0; + let mut dw_capture_flags: u32 = 0; + let mut device_position: u64 = 0; + let mut qpc_position: u64 = 0; + + self.capture_client.as_ref().unwrap().GetBuffer( + &mut data_ptr as *mut *mut u8, + &mut frames as *mut u32, + &mut dw_capture_flags as *mut u32, + &mut device_position as *mut u64, + &mut qpc_position as *mut u64, + )?; + + let num_block_align: u16 = + self.format.Format.nChannels * self.format.Format.wBitsPerSample / 8u16; + + Ok(BufferPacket { + data: data_ptr, + frames, + size: frames * num_block_align as u32, + }) + } + + pub unsafe fn release_buffer(&mut self, frames: u32) -> Result<()> { + self.capture_client + .as_ref() + .unwrap() + .ReleaseBuffer(frames)?; + + self.sample_ready_key = + MFPutWaitingWorkItem(self.ev_sample_ready, 0, &self.sample_ready_result)?; + + Ok(()) + } + + pub unsafe fn start(&mut self, callback: Box) { + let mut task_id: u32 = 0; + + MFStartup(MF_SDK_VERSION << 16 | MF_API_VERSION, MFSTARTUP_LITE).unwrap(); + MFLockSharedWorkQueue("Capture", 0, &mut task_id, &mut self.queue_id).unwrap(); + + let mut audio_params = AUDIOCLIENT_ACTIVATION_PARAMS { + ActivationType: AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, + Anonymous: AUDIOCLIENT_ACTIVATION_PARAMS_0 { + ProcessLoopbackParams: AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS { + TargetProcessId: self.target_pid, + ProcessLoopbackMode: if self.include_process_tree { + PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE + } else { + PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE + }, + }, + }, + }; + + let activate_params = ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_BLOB.0 as u16, + Anonymous: PROPVARIANT_0_0_0 { + blob: BLOB { + cbSize: std::mem::size_of::() as u32, + pBlobData: (&mut audio_params) as *mut AUDIOCLIENT_ACTIVATION_PARAMS as *mut u8, + }, + }, + ..Default::default() + }); + + let activate_params: PROPVARIANT = PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: activate_params, + }, + }; + + let completion_handler = CompletionHandler::new(); + let completion_handler_interface: IActivateAudioInterfaceCompletionHandler = + completion_handler.clone().into(); + + let op = ActivateAudioInterfaceAsync( + VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, + &IAudioClient::IID as *const GUID, + &activate_params, + &completion_handler_interface, + ) + .unwrap(); + + completion_handler.block_on(); + + let mut activate_result = HRESULT(0); + let mut activated_interface: Option = None; + + op.GetActivateResult( + &mut activate_result as *mut HRESULT, + &mut activated_interface as *mut Option, + ) + .unwrap(); + + activate_result.ok().unwrap(); + + let activated_interface = activated_interface.unwrap(); + let audio_client: IAudioClient = core::mem::transmute(activated_interface); + self.audio_client = Some(audio_client); + let audio_client = self.audio_client.as_ref().unwrap(); + + audio_client + .Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK + | AUDCLNT_STREAMFLAGS_EVENTCALLBACK + | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, + 0, + 0, + &self.format as *const WAVEFORMATEXTENSIBLE as _, //capture_format, + null(), + ) + .unwrap(); + + let capture_client = audio_client.GetService::().unwrap(); + self.capture_client = Some(capture_client); + + let sample_capturer: IMFAsyncCallback = AsyncCallback::create( + self.queue_id, + Some(callback), + self as *const LoopbackCapture as *mut LoopbackCapture, + ); + + let ev_sample_ready = CreateEventW(null(), false, false, None).unwrap(); + + let async_result = MFCreateAsyncResult(None, &sample_capturer, None).unwrap(); + self.sample_ready_result = Some(async_result); + + audio_client.SetEventHandle(ev_sample_ready).unwrap(); + + let (start_capture, receiver) = + AsyncCallback::with_receiver(MFASYNC_CALLBACK_QUEUE_MULTITHREADED); + + MFPutWorkItem2( + MFASYNC_CALLBACK_QUEUE_MULTITHREADED, + 0, + &start_capture, + None, + ) + .unwrap(); + + receiver.recv().ok(); + + audio_client.Start().unwrap(); + + self.sample_ready_key = + MFPutWaitingWorkItem(ev_sample_ready, 0, &self.sample_ready_result).unwrap(); + + self.ev_sample_ready = ev_sample_ready; + } + + pub unsafe fn stop(&mut self) { + if self.sample_ready_key != 0 { + MFCancelWorkItem(self.sample_ready_key).unwrap(); + self.sample_ready_key = 0; + } + + if let Some(client) = &self.audio_client { + client.Stop().unwrap(); + self.audio_client = None; + } + + self.sample_ready_result = None; + + if self.queue_id != 0 { + MFUnlockWorkQueue(self.queue_id).unwrap(); + self.queue_id = 0; + } + } +} + +#[implement(IActivateAudioInterfaceCompletionHandler)] +#[derive(Clone)] +struct CompletionHandler { + completed: Arc, + waker: Arc>>, +} + +impl CompletionHandler { + fn new() -> CompletionHandler { + CompletionHandler { + completed: Arc::new(AtomicBool::new(false)), + waker: Arc::new(Mutex::new(None)), + } + } +} + +impl IActivateAudioInterfaceCompletionHandler_Impl for CompletionHandler { + fn ActivateCompleted(&self, _: &Option) -> Result<()> { + let self_ptr = self as *const CompletionHandler as *mut CompletionHandler; + unsafe { + (*self_ptr).completed.store(true, Ordering::SeqCst); + } + + if let Some(waker) = self.waker.lock().take() { + waker.wake() + }; + + Ok(()) + } +} + +impl Future for CompletionHandler { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.completed.load(Ordering::SeqCst) { + Poll::Ready(()) + } else { + self.waker.lock().replace(cx.waker().clone()); + Poll::Pending + } + } +} + +#[implement(IMFAsyncCallback)] +struct AsyncCallback { + queue_id: u32, + sender: Option>, + callback: Option>, + capture_ptr: *mut LoopbackCapture, +} + +impl AsyncCallback { + fn create( + queue_id: u32, + callback: Option>, + capture_ptr: *mut LoopbackCapture, + ) -> IMFAsyncCallback { + AsyncCallback { + queue_id, + sender: None, + callback, + capture_ptr, + } + .into() + } + + fn with_receiver(queue_id: u32) -> (IMFAsyncCallback, Receiver<()>) { + let (tx, rx) = mpsc::channel(); + ( + AsyncCallback { + queue_id, + sender: Some(tx), + callback: None, + capture_ptr: null_mut(), + } + .into(), + rx, + ) + } +} + +impl IMFAsyncCallback_Impl for AsyncCallback { + fn GetParameters(&self, pdwflags: *mut u32, pdwqueue: *mut u32) -> Result<()> { + unsafe { + *pdwflags = 0; + *pdwqueue = self.queue_id; + } + Ok(()) + } + + fn Invoke(&self, _result: &Option) -> Result<()> { + if let Some(sender) = self.sender.as_ref() { + sender.send(()).expect("send() failed."); + } + + if let Some(c) = self.callback.as_ref() { + c(unsafe { &mut *self.capture_ptr }); + } + Ok(()) + } +} + +#[repr(C)] +pub struct BufferPacket { + pub data: *const u8, + pub frames: u32, + pub size: u32, +} + +pub struct LoopbackSession { + pub src_id: Uuid, + pub dst_id: Uuid, + capture: Box, +} + +impl Drop for LoopbackSession { + fn drop(&mut self) { + unsafe { + self.capture.stop(); + } + } +} + +impl LoopbackSession { + pub fn start( + src_id: Uuid, + dst_id: Uuid, + process_id: u32, + target_device: &IMMDevice, + ) -> Result { + let render_client = RenderClient::new(target_device)?; + let mut capture = Box::new(LoopbackCapture::new( + process_id, + *render_client.wave_format(), + )); + + let frame_callback = Box::new(move |capture: &mut LoopbackCapture| unsafe { + let frames = capture + .get_next_packet_size() + .expect("Failed to get next packet size"); + + if frames == 0 { + return; + } + + let packet = capture.get_buffer().expect("Failed to get buffer"); + + render_client.render_frames(packet.data, packet.frames).ok(); + + capture + .release_buffer(frames) + .expect("Failed to release buffer"); + }); + + unsafe { + capture.start(frame_callback); + } + + Ok(Self { + src_id, + dst_id, + capture, + }) + } +} diff --git a/crates/nodio-win32/src/node.rs b/crates/nodio-win32/src/node.rs new file mode 100644 index 0000000..50633f9 --- /dev/null +++ b/crates/nodio-win32/src/node.rs @@ -0,0 +1,16 @@ +use nodio_core::Uuid; + +#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +pub enum NodeConnectionKind { + DefaultEndpoint, + Loopback, + Listen, +} + +#[derive(Debug, Copy, Clone)] +pub struct NodeConnectionInfo { + pub id: Uuid, + pub src_id: Uuid, + pub dst_id: Uuid, + pub kind: NodeConnectionKind, +} diff --git a/crates/nodio-win32/src/render.rs b/crates/nodio-win32/src/render.rs new file mode 100644 index 0000000..57ddad3 --- /dev/null +++ b/crates/nodio-win32/src/render.rs @@ -0,0 +1,70 @@ +use crate::device::MMDeviceExt; +use log::warn; +use std::ptr::null; +use windows::Win32::Media::Audio::{ + IAudioClient, IAudioRenderClient, IMMDevice, AUDCLNT_SHAREMODE_SHARED, WAVEFORMATEX, + WAVEFORMATEXTENSIBLE, +}; +use windows::Win32::Media::KernelStreaming::WAVE_FORMAT_EXTENSIBLE; + +pub struct RenderClient { + audio_client: IAudioClient, + render_client: IAudioRenderClient, + wave_format: WAVEFORMATEXTENSIBLE, +} + +impl Drop for RenderClient { + fn drop(&mut self) { + if let Err(err) = unsafe { self.audio_client.Stop() } { + warn!("Could not stop render client: {}", err); + } + } +} + +impl RenderClient { + pub fn new(device: &IMMDevice) -> windows::core::Result { + unsafe { + let audio_client = device.activate::()?; + let pwfx: *mut WAVEFORMATEX = audio_client.GetMixFormat()?; + audio_client.Initialize(AUDCLNT_SHAREMODE_SHARED, 0, 0, 0, pwfx, null())?; + let render_client = audio_client.GetService::()?; + + let mut wave_format: WAVEFORMATEXTENSIBLE = std::mem::zeroed(); + + if (*pwfx).wFormatTag == WAVE_FORMAT_EXTENSIBLE as _ { + wave_format = *(pwfx as *mut WAVEFORMATEXTENSIBLE) + } else { + wave_format.Format = *pwfx; + } + + audio_client.Start()?; + + Ok(Self { + audio_client, + render_client, + wave_format, + }) + } + } + + pub fn wave_format(&self) -> &WAVEFORMATEXTENSIBLE { + &self.wave_format + } + + pub fn render_frames(&self, data_in: *const u8, frames: u32) -> windows::core::Result<()> { + unsafe { + let padding = self.audio_client.GetCurrentPadding()?; + let frames = frames - padding; + + let data_out = self.render_client.GetBuffer(frames)?; + + let data_len = frames * self.wave_format.Format.nBlockAlign as u32; + + std::ptr::copy(data_in, data_out, data_len as usize); + + self.render_client.ReleaseBuffer(frames, 0)?; + } + + Ok(()) + } +} diff --git a/crates/nodio-win32/src/session.rs b/crates/nodio-win32/src/session.rs new file mode 100644 index 0000000..e5c7f84 --- /dev/null +++ b/crates/nodio-win32/src/session.rs @@ -0,0 +1,289 @@ +use std::mem::size_of_val; +use std::ptr::{null, null_mut}; +use std::sync::mpsc::channel; +use std::sync::Arc; +use std::time::Duration; + +use log::{trace, warn}; +use notify_thread::JoinHandle; +use parking_lot::Mutex; +use widestring::U16Str; +use windows::core::{Interface, PCWSTR, PWSTR}; +use windows::Win32::Foundation::{CloseHandle, BOOL, HINSTANCE}; +use windows::Win32::Media::Audio as windows_audio; +use windows::Win32::Media::Audio::Endpoints::IAudioMeterInformation; +use windows::Win32::Media::Audio::{ + AudioSessionState, IAudioSessionControl, IAudioSessionControl2, IAudioSessionEvents, + ISimpleAudioVolume, +}; +use windows::Win32::System::ProcessStatus::{ + K32EnumProcessModulesEx, K32GetModuleBaseNameW, K32GetModuleFileNameExW, LIST_MODULES_ALL, +}; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; +use windows::Win32::UI::Shell::SHLoadIndirectString; + +use nodio_core::{Node, NodeKind, Uuid}; + +use crate::custom::{AudioSessionEvent, AudioSessionEvents, SessionState}; +use crate::pwstr_to_string; +use crate::Callback; + +#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +pub enum AudioSessionKind { + Application, + Other, +} + +#[derive(Clone)] +pub struct AudioSession { + id: Uuid, + process_id: u32, + display_name: String, + filename: String, + kind: AudioSessionKind, + control: IAudioSessionControl, + simple_audio_volume: ISimpleAudioVolume, + meter: IAudioMeterInformation, + events: IAudioSessionEvents, + event_thread_handle: Arc>>>, + event_callback: Arc>>>, +} + +impl Drop for AudioSession { + fn drop(&mut self) { + trace!("dropping audio session {}", self.display_name); + unsafe { + self.control + .UnregisterAudioSessionNotification(self.events.clone()) + .ok(); + } + if let Some(t) = self.event_thread_handle.lock().take() { + t.notify(); + } + trace!("audio session dropped"); + } +} + +unsafe impl Send for AudioSession {} +unsafe impl Sync for AudioSession {} + +impl AudioSession { + pub fn new(control: IAudioSessionControl) -> windows::core::Result { + let control2: IAudioSessionControl2 = control.cast()?; + let simple_audio_volume: ISimpleAudioVolume = control.cast()?; + let meter: IAudioMeterInformation = control.cast()?; + + let process_id = unsafe { control2.GetProcessId()? }; + let display_name_pwstr: PWSTR = unsafe { control.GetDisplayName()? }; + let mut display_name: String = pwstr_to_string(display_name_pwstr); + + if display_name.starts_with('@') { + let mut text = [0; 512]; + unsafe { + SHLoadIndirectString( + PCWSTR(display_name_pwstr.0 as *const u16), + &mut text, + null_mut(), + )? + }; + + let len = text.iter().take_while(|&&c| c != 0).count(); + + display_name = String::from_utf16_lossy(&text[..len]); + } + + if display_name.is_empty() { + display_name = get_process_name(process_id)?; + } + + let mut filename = String::new(); + if process_id != 0 { + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, + BOOL::from(false), + process_id, + ) + }?; + + let mut module_filename = [0; 2048]; + let nread = unsafe { + K32GetModuleFileNameExW(handle, HINSTANCE(0), module_filename.as_mut_slice()) + } as usize; + + filename = String::from_utf16_lossy(&module_filename[..nread]); + unsafe { CloseHandle(handle) }; + } + + let kind = if filename.is_empty() { + AudioSessionKind::Other + } else { + AudioSessionKind::Application + }; + + let (event_tx, event_rx) = channel(); + + let events = AudioSessionEvents::create(event_tx); + let session_event_callback: Arc>>> = + Arc::new(Mutex::new(None)); + let session_event_thread = { + let session_event_callback = session_event_callback.clone(); + + notify_thread::spawn(move |thread| loop { + match event_rx.recv_timeout(Duration::from_millis(100)) { + Ok(event) => { + trace!("Session event: {:?}", event); + + if let Some(cb) = session_event_callback.lock().as_ref() { + cb(event); + } + } + + _ if thread.notified() => { + trace!("Session event thread ended"); + return; + } + _ => {} + } + }) + }; + + unsafe { + control + .RegisterAudioSessionNotification(events.clone()) + .unwrap(); + }; + + Ok(Self { + id: Uuid::new_v4(), + process_id, + display_name, + filename, + kind, + control, + simple_audio_volume, + meter, + events, + event_thread_handle: Arc::new(Mutex::new(Some(session_event_thread))), + event_callback: session_event_callback, + }) + } + + pub fn set_event_callback(&mut self, cb: T) + where + T: Fn(AudioSessionEvent) + Send + Sync + 'static, + { + let _ = self.event_callback.lock().insert(Box::new(cb)); + } + + pub fn is_active(&self) -> bool { + let state: AudioSessionState = unsafe { self.control.GetState() }.unwrap(); + + state == windows_audio::AudioSessionStateActive + } + + pub fn set_master_volume(&self, volume: f32) { + unsafe { + if let Err(err) = self.simple_audio_volume.SetMasterVolume(volume, null()) { + warn!( + "Failed to set volume for session {}: {:?}", + self.process_id, err + ); + } + } + } + + pub fn master_volume(&self) -> f32 { + unsafe { self.simple_audio_volume.GetMasterVolume().unwrap_or(0.0) as f32 } + } + + pub fn _state(&self) -> SessionState { + match unsafe { self.control.GetState() }.unwrap_or(windows_audio::AudioSessionStateExpired) + { + windows_audio::AudioSessionStateActive => SessionState::Active, + windows_audio::AudioSessionStateInactive => SessionState::Inactive, + windows_audio::AudioSessionStateExpired => SessionState::Expired, + _ => SessionState::Expired, + } + } + + pub fn _muted(&self) -> bool { + unsafe { + self.simple_audio_volume + .GetMute() + .unwrap_or_default() + .into() + } + } + + pub fn peak_values(&self) -> windows::core::Result<(f32, f32)> { + unsafe { + let channel_count = usize::min(2, self.meter.GetMeteringChannelCount()? as usize); + + let mut values = [0.0; 2]; + self.meter.GetChannelsPeakValues(values.as_mut_slice())?; + + if channel_count == 1 { + Ok((values[0], values[0])) + } else { + Ok((values[0], values[1])) + } + } + } + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn process_id(&self) -> u32 { + self.process_id + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn filename(&self) -> &str { + &self.filename + } + + pub fn kind(&self) -> AudioSessionKind { + self.kind + } +} + +pub fn session_node_match(node: &Node, session: &AudioSession) -> bool { + node.process_id == Some(session.process_id) + || (node.kind == NodeKind::Application + && node.display_name == session.display_name + && node.filename == session.filename + && session.kind == AudioSessionKind::Application) + || (node.kind == NodeKind::InputDevice + && node.display_name == session.display_name + && session.kind == AudioSessionKind::Other) +} + +fn get_process_name(pid: u32) -> windows::core::Result { + unsafe { + let proc = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid)?; + + let mut hmodule = HINSTANCE::default(); + let mut bytes_needed = 0; + if K32EnumProcessModulesEx( + proc, + &mut hmodule, + size_of_val(&hmodule) as _, + &mut bytes_needed, + LIST_MODULES_ALL, + ) + .as_bool() + { + let mut name: [u16; 256] = [0; 256]; + let len = K32GetModuleBaseNameW(proc, hmodule, name.as_mut_slice()); + + Ok(U16Str::from_ptr(name.as_ptr(), len as _).to_string_lossy()) + } else { + Ok(String::default()) + } + } +} diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..e6b4844e47bc77937e8891f545f293155145807e GIT binary patch literal 29111 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