diff --git a/Cargo.lock b/Cargo.lock index 60bd5b7..236707e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,144 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -24,12 +162,261 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -42,7 +429,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -51,6 +447,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "1.3.2" @@ -63,6 +465,37 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -74,6 +507,26 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -81,6 +534,68 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -93,6 +608,60 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -103,6 +672,55 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -132,7 +750,7 @@ dependencies = [ "alsa", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -146,15 +764,80 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.54.0", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -162,7 +845,207 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", - "objc2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", ] [[package]] @@ -179,6 +1062,64 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.2" @@ -195,6 +1136,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "extended" version = "0.1.0" @@ -207,18 +1183,137 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -232,11 +1327,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -260,11 +1375,307 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows 0.62.2", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] [[package]] name = "indexmap" @@ -273,7 +1684,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", ] [[package]] @@ -298,6 +1737,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -326,6 +1795,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -337,6 +1816,45 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -349,12 +1867,73 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -376,12 +1955,98 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + [[package]] name = "ndk" version = "0.9.0" @@ -393,6 +2058,7 @@ dependencies = [ "log", "ndk-sys", "num_enum", + "raw-window-handle", "thiserror 1.0.69", ] @@ -411,6 +2077,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-complex" version = "0.4.6" @@ -447,6 +2119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -471,6 +2144,22 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.5" @@ -480,6 +2169,36 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-audio-toolbox" version = "0.3.2" @@ -488,11 +2207,35 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] @@ -502,7 +2245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -514,7 +2257,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] @@ -525,7 +2280,44 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] @@ -534,13 +2326,170 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "objc2", + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -549,18 +2498,257 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -570,6 +2758,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "primal-check" version = "0.3.4" @@ -597,14 +2791,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "proptest" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", "rand", @@ -616,12 +2816,33 @@ dependencies = [ "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -681,12 +2902,148 @@ dependencies = [ "rand_core", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustfft" version = "6.4.1" @@ -701,6 +3058,19 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.5" @@ -710,7 +3080,7 @@ dependencies = [ "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -727,7 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -741,12 +3111,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -790,18 +3178,187 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "soundcraft" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "log", + "rfd", + "serde_json", + "soundcraft-engine", + "soundcraft-playback", + "soundcraft-ui-egui", +] + [[package]] name = "soundcraft-audio-io" version = "0.1.0" @@ -814,6 +3371,27 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "soundcraft-automation" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "soundcraft-engine", + "soundcraft-time", +] + +[[package]] +name = "soundcraft-cli" +version = "0.1.0" +dependencies = [ + "serde_json", + "soundcraft-audio-io", + "soundcraft-automation", + "soundcraft-dsp", + "soundcraft-engine", +] + [[package]] name = "soundcraft-dsp" version = "0.1.0" @@ -899,6 +3477,47 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "soundcraft-ui-egui" +version = "0.1.0" +dependencies = [ + "egui", + "egui_kittest", + "image", + "log", + "serde", + "serde_json", + "soundcraft-audio-io", + "soundcraft-dsp", + "soundcraft-engine", + "soundcraft-midi", + "soundcraft-mix", + "soundcraft-model", + "soundcraft-playback", + "soundcraft-time", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strength_reduce" version = "0.2.4" @@ -1122,6 +3741,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1131,10 +3761,19 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1175,6 +3814,30 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.2" @@ -1184,6 +3847,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1214,6 +3890,38 @@ dependencies = [ "winnow", ] +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "transpose" version = "0.2.3" @@ -1224,18 +3932,118 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1320,6 +4128,141 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -1330,6 +4273,221 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1345,26 +4503,103 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "windows-core", + "windows-core 0.54.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", + "windows-result 0.1.2", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -1374,6 +4609,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -1383,6 +4636,33 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1416,13 +4696,39 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -1435,6 +4741,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -1447,6 +4759,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -1459,12 +4777,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -1477,6 +4807,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -1489,6 +4825,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -1501,6 +4843,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -1513,6 +4861,63 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "1.0.4" @@ -1528,6 +4933,75 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "xtask" version = "0.1.0" @@ -1535,6 +5009,135 @@ dependencies = [ "serde_json", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.60" @@ -1555,8 +5158,124 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 486bd08..33911bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" -members = ["crates/*", "xtask"] -default-members = ["crates/*", "xtask"] +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] version = "0.1.0" diff --git a/apps/soundcraft-cli/Cargo.toml b/apps/soundcraft-cli/Cargo.toml new file mode 100644 index 0000000..fad22e1 --- /dev/null +++ b/apps/soundcraft-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "soundcraft-cli" +description = "SoundCraft command line: headless editing, conversion, bounce, MCP server, app control" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[[bin]] +name = "soundcraft-cli" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +soundcraft-engine.workspace = true +soundcraft-automation.workspace = true +soundcraft-audio-io.workspace = true +soundcraft-dsp.workspace = true +serde_json.workspace = true diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs new file mode 100644 index 0000000..9dc8ec3 --- /dev/null +++ b/apps/soundcraft-cli/src/main.rs @@ -0,0 +1,338 @@ +//! `soundcraft-cli`: SoundCraft without a window. +//! +//! ```text +//! soundcraft-cli info FILE audio file or .scraft summary +//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] +//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect] +//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line) +//! soundcraft-cli commands [FILTER] list commands +//! soundcraft-cli describe ID one command's details +//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel +//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio +//! soundcraft-cli parity [--write PATH] parity report (markdown) +//! soundcraft-cli plugins list built-in plugins +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use serde_json::{Value, json}; +use soundcraft_automation::{Backend, Headless, Remote, mcp::Server}; +use soundcraft_engine::Engine; +use std::io::Write; +use std::process::ExitCode; + +/// Print without panicking on a closed pipe. +macro_rules! outln { + ($($t:tt)*) => {{ + let mut o = std::io::stdout().lock(); + if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; } + }}; +} + +fn arg_value(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} + +fn fail(msg: impl std::fmt::Display) -> ExitCode { + eprintln!("soundcraft-cli: {msg}"); + ExitCode::FAILURE +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let Some(cmd) = args.first().cloned() else { + eprintln!("usage: soundcraft-cli … (see --help)"); + return ExitCode::FAILURE; + }; + let rest: Vec = args.iter().skip(1).cloned().collect(); + match cmd.as_str() { + "--version" | "-V" | "version" => { + outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION")); + ExitCode::SUCCESS + } + "--help" | "-h" | "help" => { + outln!("{}", include_str!("main.rs").lines().skip(3).take(14).map(|l| l.trim_start_matches("//! ")).collect::>().join("\n")); + ExitCode::SUCCESS + } + "info" => info(&rest), + "convert" => convert(&rest), + "run" => run(&rest), + "script" => script(&rest), + "commands" => commands(&rest), + "describe" => describe(&rest), + "app" => app(&rest), + "mcp" => mcp(&rest), + "parity" => parity(&rest), + "plugins" => { + for p in soundcraft_dsp::plugins() { + outln!("{:<20} {:<22} {:?}{}", p.id, p.name, p.category, if p.is_instrument { " (instrument)" } else { "" }); + } + ExitCode::SUCCESS + } + other => fail(format!("unknown subcommand `{other}`")), + } +} + +fn info(args: &[String]) -> ExitCode { + let Some(path) = args.first() else { return fail("info FILE") }; + if path.ends_with(".scraft") { + let mut e = Engine::default(); + if let Err(err) = soundcraft_engine::io::open_session(&mut e, path) { + return fail(err); + } + outln!("{}", soundcraft_engine::inspect::session_text(&e)); + return ExitCode::SUCCESS; + } + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => return fail(format!("{path}: {e}")), + }; + let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str()); + match soundcraft_audio_io::probe(&bytes, ext) { + Ok(i) => { + outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default()); + ExitCode::SUCCESS + } + Err(e) => fail(format!("{path}: {e}")), + } +} + +fn convert(args: &[String]) -> ExitCode { + let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") }; + let bytes = match std::fs::read(input) { + Ok(b) => b, + Err(e) => return fail(format!("{input}: {e}")), + }; + let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); + let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { + Ok(x) => x, + Err(e) => return fail(format!("{input}: {e}")), + }; + if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::().ok()) { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); + buf.sample_rate = rate; + } + if args.iter().any(|a| a == "--normalize") { + soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); + } + let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); + let format = match out_ext.as_str() { + "aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff, + "flac" => soundcraft_audio_io::FileFormat::Flac, + _ => soundcraft_audio_io::FileFormat::Wav, + }; + let bit_depth = match arg_value(args, "--bit-depth").as_deref() { + Some("16") => soundcraft_audio_io::BitDepth::Int16, + Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, + _ => soundcraft_audio_io::BitDepth::Int24, + }; + let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }; + match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) { + Ok(()) => { + outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs()); + ExitCode::SUCCESS + } + Err(e) => fail(e), + } +} + +fn load_engine(args: &[String]) -> Result { + if let Some(p) = arg_value(args, "--in") { + let mut e = Engine::default(); + soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?; + Ok(e) + } else if args.iter().any(|a| a == "--demo" || a == "--sample") { + Ok(soundcraft_engine::demo::demo_engine()) + } else { + Ok(Engine::default()) + } +} + +fn finish(e: &mut Engine, args: &[String]) -> ExitCode { + if let Some(out) = arg_value(args, "--bounce") { + let mut p = json!({"path": out}); + if let Some(s) = arg_value(args, "--start") { + p["start"] = time_arg(&s); + } + if let Some(s) = arg_value(args, "--end") { + p["end"] = time_arg(&s); + } + match e.execute("file.bounce_mix", &p) { + Ok(v) => outln!("{v}"), + Err(err) => return fail(err), + } + } + if let Some(path) = arg_value(args, "--save") { + match e.execute("session.save", &json!({"path": path})) { + Ok(v) => outln!("{v}"), + Err(err) => return fail(err), + } + } + if args.iter().any(|a| a == "--inspect") { + outln!("{}", serde_json::to_string_pretty(&soundcraft_engine::inspect::session(e, false)).unwrap_or_default()); + } + ExitCode::SUCCESS +} + +/// `12.5` → seconds; anything else is passed through as a time string. +fn time_arg(s: &str) -> Value { + s.parse::().map_or_else(|_| json!(s), |f| json!({"seconds": f})) +} + +fn run(args: &[String]) -> ExitCode { + let mut e = match load_engine(args) { + Ok(e) => e, + Err(err) => return fail(err), + }; + let mut i = 0; + while i < args.len() { + if args.get(i).is_some_and(|a| a == "--cmd") + && let Some(spec) = args.get(i + 1) + { + let (id, params) = match spec.split_once('=') { + Some((id, p)) => (id.to_string(), serde_json::from_str::(p).unwrap_or(json!({}))), + None => (spec.clone(), json!({})), + }; + match e.execute(&id, ¶ms) { + Ok(v) => outln!("{id}: {v}"), + Err(err) => return fail(format!("{id}: {err}")), + } + i += 1; + } + i += 1; + } + finish(&mut e, args) +} + +fn script(args: &[String]) -> ExitCode { + let Some(path) = args.first() else { return fail("script FILE") }; + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => return fail(format!("{path}: {e}")), + }; + let mut e = match load_engine(args) { + Ok(e) => e, + Err(err) => return fail(err), + }; + let keep = args.iter().any(|a| a == "--keep-going"); + for (n, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (id, p) = line.split_once(char::is_whitespace).unwrap_or((line, "{}")); + let params = match serde_json::from_str::(p.trim()) { + Ok(v) => v, + Err(err) => return fail(format!("{path}:{}: bad JSON: {err}", n + 1)), + }; + match e.execute(id, ¶ms) { + Ok(v) => outln!("{id}: {v}"), + Err(err) => { + eprintln!("{path}:{}: {id}: {err}", n + 1); + if !keep { + return ExitCode::FAILURE; + } + } + } + } + finish(&mut e, args) +} + +fn commands(args: &[String]) -> ExitCode { + let filter = args.first().cloned().unwrap_or_default(); + let e = Engine::default(); + for c in soundcraft_engine::command_specs() { + if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) { + continue; + } + let i = c.info(&e); + outln!("{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > ")); + } + ExitCode::SUCCESS +} + +fn describe(args: &[String]) -> ExitCode { + let Some(id) = args.first() else { return fail("describe ID") }; + match soundcraft_engine::find_command(id) { + Some(c) => { + outln!("{}", serde_json::to_string_pretty(&c.info(&Engine::default())).unwrap_or_default()); + ExitCode::SUCCESS + } + None => fail(format!("unknown command `{id}`")), + } +} + +fn app(args: &[String]) -> ExitCode { + let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into()); + let positional: Vec<&String> = args + .iter() + .enumerate() + .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))) + .map(|(_, a)| a) + .collect(); + let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") }; + let params = positional.get(1).and_then(|p| serde_json::from_str::(p).ok()).unwrap_or(json!({})); + let mut r = Remote::new(&port); + // A bare command id is shorthand for engine.execute. + let (m, p) = if method.contains('.') + && !method.starts_with("ui.") + && !method.starts_with("engine.") + && !method.starts_with("session.") + && !method.starts_with("app.") + { + ("engine.execute".to_string(), json!({"command": method, "params": params})) + } else { + (method.to_string(), params) + }; + match r.call(&m, p) { + Ok(v) => { + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + ExitCode::SUCCESS + } + Err(e) => fail(e), + } +} + +fn mcp(args: &[String]) -> ExitCode { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let r = if let Some(port) = arg_value(args, "--connect") { + let mut s = Server::new(Remote::new(&port)); + s.serve(stdin.lock(), stdout.lock()) + } else { + let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let mut s = Server::new(Headless::new(e)); + s.serve(stdin.lock(), stdout.lock()) + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => fail(e), + } +} + +fn parity(args: &[String]) -> ExitCode { + let p = soundcraft_engine::catalog::parity_json(); + let mut md = String::new(); + md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n"); + md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n"); + md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n"); + md.push_str(&format!( + "**Menu items implemented by the engine: {} / {} ({}%)**\n\n| Menu | Implemented | Total |\n|---|---:|---:|\n", + p["implemented"], p["total"], p["percent"] + )); + for m in p["per_menu"].as_array().cloned().unwrap_or_default() { + md.push_str(&format!("| {} | {} | {} |\n", m["menu"].as_str().unwrap_or(""), m["implemented"], m["total"])); + } + md.push_str("\n## Not yet implemented\n\n"); + for m in p["missing"].as_array().cloned().unwrap_or_default() { + md.push_str(&format!("- {}\n", m.as_str().unwrap_or(""))); + } + if let Some(path) = arg_value(args, "--write") { + if let Err(e) = std::fs::write(&path, &md) { + return fail(format!("{path}: {e}")); + } + outln!("wrote {path}: {}% ({}/{})", p["percent"], p["implemented"], p["total"]); + } else { + outln!("{md}"); + } + ExitCode::SUCCESS +} diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml new file mode 100644 index 0000000..eb9569f --- /dev/null +++ b/apps/soundcraft/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "soundcraft" +description = "SoundCraft desktop app" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +default-run = "soundcraft" + +[lints] +workspace = true + +[dependencies] +soundcraft-engine.workspace = true +soundcraft-ui-egui.workspace = true +soundcraft-playback.workspace = true +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } +egui.workspace = true +serde_json.workspace = true +log.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd.workspace = true diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs new file mode 100644 index 0000000..7c0c3e0 --- /dev/null +++ b/apps/soundcraft/src/control_server.rs @@ -0,0 +1,66 @@ +//! Loopback JSON-lines control server: one request per line, one reply per line. +//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps. + +use serde_json::{Value, json}; +use soundcraft_ui_egui::ControlRequest; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + return rx; + } + }; + let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); + eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}"); + println!("SOUNDCRAFT_CONTROL_PORT={actual}"); + let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx)); + } + }); + if let Err(e) = spawned { + eprintln!("soundcraft: control thread failed: {e}"); + } + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs new file mode 100644 index 0000000..0de0592 --- /dev/null +++ b/apps/soundcraft/src/main.rs @@ -0,0 +1,177 @@ +//! SoundCraft desktop app. +//! +//! Usage: `soundcraft [--demo] [--control ] [--no-audio] [session.scraft | audio files…]` +//! +//! `--control ` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server +//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` → +//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod control_server; + +use soundcraft_engine::Engine; +use soundcraft_ui_egui::{Services, SoundApp, UiState}; +use std::sync::Arc; + +struct App(SoundApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + // Files dropped on the window: sessions open, audio/MIDI import. + let dropped: Vec = + ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect()); + for p in dropped { + open_path(&mut self.0, &p); + } + if self.0.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + let title = format!("{} — SoundCraft", self.0.engine.session().name); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } + fn on_exit(&mut self) { + save_prefs(&self.0.ui); + } +} + +fn open_path(app: &mut SoundApp, p: &str) { + let lower = p.to_ascii_lowercase(); + let r = if lower.ends_with(".scraft") { + app.run("session.open", serde_json::json!({"path": p})) + } else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") { + app.run("file.import_midi", serde_json::json!({"path": p})) + } else { + let at = app.engine.session().edit.selection.start; + app.run("file.import_audio", serde_json::json!({"path": p, "at": at})) + }; + if let Err(e) = r { + log::warn!("{p}: {e}"); + } +} + +fn prefs_path() -> Option { + let base = if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("soundcraft")) + }; + base.map(|b| b.join("ui.json")) +} + +fn load_prefs() -> Option { + if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() { + return None; + } + let bytes = std::fs::read(prefs_path()?).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn save_prefs(ui: &UiState) { + if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let mut ui = ui.clone(); + ui.plugin_windows.clear(); + ui.status.clear(); + if let Ok(bytes) = serde_json::to_vec_pretty(&ui) { + let _ = std::fs::write(&p, bytes); + } + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|purpose: &str, _exts: &[&str]| { + let d = rfd::FileDialog::new(); + let d = match purpose { + "session.open" => d.add_filter("SoundCraft Session", &["scraft"]), + "file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]), + _ => d.add_filter( + "Audio", + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"], + ), + }; + d.pick_file().map(|p| p.to_string_lossy().into_owned()) + })), + pick_save: Some(Box::new(|_purpose: &str, default: &str| { + let path = std::path::Path::new(default); + let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]); + if let Some(n) = path.file_name().and_then(|n| n.to_str()) { + d = d.set_file_name(n); + } + d.save_file().map(|p| p.to_string_lossy().into_owned()) + })), + } +} + +fn main() -> eframe::Result { + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("SoundCraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + if std::env::var_os("RUST_LOG").is_none() { + // Quiet by default; `RUST_LOG=info` for more. + } + let demo = args.iter().any(|a| a == "--demo" || a == "--sample"); + let no_audio = args.iter().any(|a| a == "--no-audio"); + let control_port: Option = args + .iter() + .position(|a| a == "--control") + .and_then(|i| args.get(i + 1)) + .and_then(|p| p.parse().ok()) + .or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok())); + let files: Vec = args + .iter() + .enumerate() + .filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))) + .map(|(_, a)| a.clone()) + .collect(); + + let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("SoundCraft") + .with_inner_size([1600.0, 1000.0]) + .with_min_inner_size([960.0, 600.0]) + .with_drag_and_drop(true) + .with_app_id("ai.storyteller.soundcraft"), + ..Default::default() + }; + eframe::run_native( + "SoundCraft", + options, + Box::new(move |cc| { + let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; + let mut app = SoundApp::new(engine, player, services()); + if let Some(ui) = load_prefs() { + app.ui = ui; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + for f in &files { + open_path(&mut app, f); + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 0000000..2e3f3f8 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "soundcraft-automation" +description = "SoundCraft agent control: MCP server (stdio JSON-RPC), headless engine backend and control-channel client" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-engine.workspace = true +soundcraft-time.workspace = true +serde_json.workspace = true +log.workspace = true + +[lints] +workspace = true diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 0000000..84d4e23 --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,155 @@ +//! Agent control for SoundCraft. +//! +//! - [`Backend`]: something that answers control-protocol methods (`engine.execute`, +//! `session.inspect`, `ui.*`…). [`Headless`] runs an in-process engine; [`Remote`] talks to a +//! running app's `--control` port. +//! - [`mcp::Server`]: a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0) +//! exposing SoundCraft as tools and resources. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod mcp; + +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::time::Duration; + +/// Answers control-protocol methods. +pub trait Backend { + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a UI is attached (screenshots, clicks). + fn has_ui(&self) -> bool; + fn describe(&self) -> String; +} + +/// An in-process engine (no UI). +pub struct Headless { + pub engine: Engine, +} + +impl Headless { + pub fn new(engine: Engine) -> Self { + Headless { engine } + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + match method { + "engine.execute" | "command" => { + let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?; + let p = params.get("params").cloned().unwrap_or(json!({})); + let r = self.engine.execute(id, &p).map_err(|e| e.to_string()); + // Headless has no audio engine: drop transport requests but keep the playhead. + self.engine.transport_requests.clear(); + r + } + "engine.commands" => self.engine.execute("engine.commands", ¶ms).map_err(|e| e.to_string()), + "engine.parity" => Ok(soundcraft_engine::catalog::parity_json()), + "session.inspect" | "document.inspect" => { + Ok(soundcraft_engine::inspect::session(&self.engine, params.get("detail").and_then(Value::as_str) == Some("full"))) + } + m if m.starts_with("ui.") => { + Err(format!("`{m}` needs the desktop app: start `soundcraft --control 0` and use `soundcraft-cli mcp --connect PORT`")) + } + other => Err(format!("unknown method `{other}`")), + } + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + format!("headless engine, session `{}`", self.engine.session().name) + } +} + +/// A TCP client for a running app's control channel. +pub struct Remote { + addr: String, + conn: Option<(TcpStream, BufReader)>, + next_id: u64, +} + +impl Remote { + /// `target` is a port (`7979`) or `host:port`. + pub fn new(target: &str) -> Self { + let addr = if target.contains(':') { target.to_string() } else { format!("127.0.0.1:{target}") }; + Remote { addr, conn: None, next_id: 1 } + } + + fn connect(&mut self) -> Result<(), String> { + if self.conn.is_some() { + return Ok(()); + } + let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?; + let s = + TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?; + s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?; + let r = s.try_clone().map_err(|e| e.to_string())?; + self.conn = Some((s, BufReader::new(r))); + Ok(()) + } + + fn roundtrip(&mut self, method: &str, params: &Value) -> Result { + self.connect()?; + let id = self.next_id; + self.next_id += 1; + let line = json!({"id": id, "method": method, "params": params}).to_string(); + let (w, r) = self.conn.as_mut().ok_or("not connected")?; + writeln!(w, "{line}").map_err(|e| e.to_string())?; + let mut buf = String::new(); + r.read_line(&mut buf).map_err(|e| e.to_string())?; + if buf.is_empty() { + return Err("connection closed".into()); + } + let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?; + if v.get("ok").and_then(Value::as_bool).unwrap_or(false) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()) + } + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + match self.roundtrip(method, ¶ms) { + Ok(v) => Ok(v), + Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => { + // Retry once on a fresh connection. + self.conn = None; + self.roundtrip(method, ¶ms) + } + Err(e) => Err(e), + } + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + format!("SoundCraft app at {}", self.addr) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headless_executes_and_inspects() { + let mut h = Headless::new(Engine::default()); + let r = h.call("engine.execute", json!({"command": "track.new", "params": {"count": 2, "format": "stereo"}})); + assert!(r.is_ok(), "{r:?}"); + let s = h.call("session.inspect", json!({})).unwrap(); + assert_eq!(s["tracks"].as_array().map(Vec::len), Some(2)); + assert!(h.call("ui.click", json!({})).is_err()); + assert!(h.call("nope", json!({})).is_err()); + } + + #[test] + fn remote_reports_unreachable() { + let mut r = Remote::new("127.0.0.1:1"); + assert!(r.call("session.inspect", json!({})).is_err()); + } +} diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs new file mode 100644 index 0000000..1b38dcc --- /dev/null +++ b/crates/automation/src/mcp.rs @@ -0,0 +1,251 @@ +//! Model Context Protocol server (stdio, newline-delimited JSON-RPC 2.0). + +use crate::Backend; +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; + +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \ +command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \ +(integers), `{\"seconds\": x}`, or time strings like \"bars_beats:5|1|000\" / \"0:12.500\". Tracks are referenced by id \ +or name. Verify your work with `inspect_session` (tracks, clips, mixer, automation, markers, selection). Programmatic \ +calls never open dialogs. Tools marked APP_ONLY need the desktop app (`soundcraft --control 0` + `soundcraft-cli mcp --connect PORT`)."; + +pub struct Server { + pub backend: B, +} + +fn tool(name: &str, desc: &str, props: Value, required: &[&str]) -> Value { + json!({"name": name, "description": desc, "inputSchema": {"type": "object", "properties": props, "required": required}}) +} + +pub fn tool_definitions() -> Vec { + vec![ + tool( + "list_commands", + "List SoundCraft commands (id, label, menu path, shortcut, params doc, enabled).", + json!({"filter": {"type": "string", "description": "substring of id or label"}}), + &[], + ), + tool( + "execute", + "Run one command by id with JSON params. Returns the command's result.", + json!({"command": {"type": "string"}, "params": {"type": "object"}}), + &["command"], + ), + tool( + "batch", + "Run several commands in order; stops at the first error unless keep_going.", + json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}), + &["calls"], + ), + tool( + "inspect_session", + "The whole session as JSON: tracks (clips, inserts, sends, automation), busses, markers, groups, selection, transport.", + json!({"detail": {"type": "string", "enum": ["summary", "full"]}}), + &[], + ), + tool( + "new_session", + "Start a new session (optionally the built-in demo).", + json!({"name": {"type": "string"}, "sample_rate": {"type": "integer"}, "template": {"type": "string", "enum": ["blank", "demo"]}}), + &[], + ), + tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]), + tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]), + tool( + "import_audio", + "Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.", + json!({"path": {"type": "string"}, "track": {"type": ["string", "integer"]}, "at": {}}), + &["path"], + ), + tool( + "bounce_mix", + "Render the mix to an audio file. Returns peak dBFS and integrated LUFS.", + json!({"path": {"type": "string"}, "format": {"type": "string", "enum": ["wav", "aiff", "flac"]}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}), + &["path"], + ), + tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]), + tool("screenshot", "APP_ONLY. Screenshot the app window (PNG).", json!({"path": {"type": "string"}}), &[]), + tool("ui_inspect", "APP_ONLY. UI state: front window, panels, layout rectangles of tracks, dialogs.", json!({}), &[]), + tool("ui_set", "APP_ONLY. Set UI state fields, e.g. {\"window\": \"Mix\"}.", json!({"state": {"type": "object"}}), &["state"]), + tool( + "menu_invoke", + "APP_ONLY. Invoke a menu item like a click (may open its dialog), e.g. \"Track > New...\".", + json!({"path": {"type": "string"}}), + &["path"], + ), + tool( + "click", + "APP_ONLY. Click at window coordinates (points).", + json!({"x": {"type": "number"}, "y": {"type": "number"}, "button": {"type": "string"}, "count": {"type": "integer"}}), + &["x", "y"], + ), + tool( + "drag", + "APP_ONLY. Drag with the primary button.", + json!({"x": {"type": "number"}, "y": {"type": "number"}, "to_x": {"type": "number"}, "to_y": {"type": "number"}}), + &["x", "y", "to_x", "to_y"], + ), + tool( + "key", + "APP_ONLY. Press a key with modifiers.", + json!({"key": {"type": "string"}, "cmd": {"type": "boolean"}, "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "ctrl": {"type": "boolean"}}), + &["key"], + ), + ] +} + +fn text_result(v: &Value) -> Value { + let text = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()); + json!({"content": [{"type": "text", "text": text}], "isError": false}) +} + +fn error_result(e: &str) -> Value { + json!({"content": [{"type": "text", "text": e}], "isError": true}) +} + +impl Server { + pub fn new(backend: B) -> Self { + Server { backend } + } + + fn exec(&mut self, cmd: &str, params: Value) -> Result { + self.backend.call("engine.execute", json!({"command": cmd, "params": params})) + } + + /// Dispatch a tool call. + pub fn call_tool(&mut self, name: &str, args: &Value) -> Value { + let a = |k: &str| args.get(k).cloned(); + let r: Result = match name { + "list_commands" => self.backend.call("engine.commands", json!({"filter": a("filter")})), + "execute" => { + let Some(cmd) = args.get("command").and_then(Value::as_str) else { return error_result("`command` required") }; + self.exec(cmd, a("params").unwrap_or(json!({}))) + } + "batch" => { + let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false); + let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut out = Vec::new(); + let mut failed = false; + for c in calls.iter().take(1000) { + let cmd = c.get("command").and_then(Value::as_str).unwrap_or(""); + let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({}))); + match r { + Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})), + Err(e) => { + out.push(json!({"command": cmd, "ok": false, "error": e})); + failed = true; + if !keep { + break; + } + } + } + } + if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } + } + "inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})), + "new_session" => self.exec("session.new", args.clone()), + "open_session" => self.exec("session.open", args.clone()), + "save_session" => self.exec("session.save", args.clone()), + "import_audio" => self.exec("file.import_audio", args.clone()), + "bounce_mix" => self.exec("file.bounce_mix", args.clone()), + "parity" => self.backend.call("engine.parity", json!({})), + "screenshot" => { + let r = self.backend.call("ui.screenshot", args.clone()); + if let Ok(v) = &r + && let Some(b64) = v.get("png_base64").and_then(Value::as_str) + { + return json!({"content": [{"type": "image", "data": b64, "mimeType": "image/png"}], "isError": false}); + } + r + } + "ui_inspect" => self.backend.call("ui.inspect", json!({})), + "ui_set" => self.backend.call("ui.set", a("state").unwrap_or(json!({}))), + "menu_invoke" => self.backend.call("ui.menu.invoke", args.clone()), + "click" => self.backend.call("ui.click", args.clone()), + "drag" => self.backend.call("ui.drag", args.clone()), + "key" => self.backend.call("ui.key", args.clone()), + other => Err(format!("unknown tool `{other}`")), + }; + match r { + Ok(v) => text_result(&v), + Err(e) => error_result(&e), + } + } + + /// Handle one JSON-RPC message; returns the reply line (None for notifications). + pub fn handle(&mut self, msg: &Value) -> Option { + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let id = id?; + let result: Result = match method { + "initialize" => { + let want = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); + let ver = if SUPPORTED.contains(&want) { want } else { PROTOCOL_VERSION }; + Ok(json!({ + "protocolVersion": ver, + "capabilities": {"tools": {}, "resources": {}}, + "serverInfo": {"name": "soundcraft", "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{INSTRUCTIONS}\nBackend: {}.", self.backend.describe()), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + Ok(self.call_tool(name, &args)) + } + "resources/list" => Ok(json!({"resources": [ + {"uri": "soundcraft://session", "name": "Session", "mimeType": "application/json", "description": "The open session as JSON"}, + {"uri": "soundcraft://commands", "name": "Commands", "mimeType": "application/json", "description": "All commands"}, + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).unwrap_or(""); + let r = match uri { + "soundcraft://session" => self.backend.call("session.inspect", json!({"detail": "full"})), + "soundcraft://commands" => self.backend.call("engine.commands", json!({})), + _ => Err(format!("unknown resource {uri}")), + }; + match r { + Ok(v) => Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})), + Err(e) => Err((-32002, e)), + } + } + "" => Err((-32600, "invalid request".into())), + other => Err((-32601, format!("method not found: {other}"))), + }; + Some(match result { + Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}), + Err((code, m)) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": m}}), + }) + } + + /// Serve until EOF. + pub fn serve(&mut self, input: impl BufRead, mut out: impl Write) -> std::io::Result<()> { + for line in input.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(Value::Array(batch)) => { + let replies: Vec = batch.iter().filter_map(|m| self.handle(m)).collect(); + if replies.is_empty() { None } else { Some(Value::Array(replies)) } + } + Ok(msg) => self.handle(&msg), + Err(e) => Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}})), + }; + if let Some(r) = reply { + writeln!(out, "{r}")?; + out.flush()?; + } + } + Ok(()) + } +} diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs new file mode 100644 index 0000000..4907cc3 --- /dev/null +++ b/crates/automation/tests/agent_tasks.rs @@ -0,0 +1,137 @@ +//! An agent completes realistic DAW tasks through MCP only, and verifies each through MCP. + +use serde_json::{Value, json}; +use soundcraft_automation::{Headless, mcp::Server}; +use soundcraft_engine::Engine; + +fn rpc(s: &mut Server, id: i64, method: &str, params: Value) -> Value { + s.handle(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap() +} + +fn tool(s: &mut Server, name: &str, args: Value) -> Value { + let r = rpc(s, 1, "tools/call", json!({"name": name, "arguments": args})); + let res = &r["result"]; + assert_eq!(res["isError"], json!(false), "{name} failed: {res}"); + let text = res["content"][0]["text"].as_str().unwrap(); + serde_json::from_str(text).unwrap_or(Value::String(text.to_string())) +} + +fn session(s: &mut Server) -> Value { + tool(s, "inspect_session", json!({"detail": "full"})) +} + +fn track<'a>(sess: &'a Value, name: &str) -> &'a Value { + sess["tracks"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no track {name}")) +} + +#[test] +fn handshake_and_tool_list() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"})); + assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft"); + let r = rpc(&mut s, 2, "tools/list", json!({})); + assert!(r["result"]["tools"].as_array().unwrap().len() >= 10); + assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none()); + let r = rpc(&mut s, 3, "nope", json!({})); + assert_eq!(r["error"]["code"], -32601); +} + +#[test] +fn agent_builds_and_mixes_a_session() { + let mut s = Server::new(Headless::new(Engine::default())); + // 1. New demo session. + tool(&mut s, "new_session", json!({"name": "Agent Mix", "template": "demo"})); + let sess = session(&mut s); + assert_eq!(sess["name"], "Agent Mix"); + assert!(sess["tracks"].as_array().unwrap().len() >= 9); + + // 2. Turn the kick down and pan the hats. + tool(&mut s, "execute", json!({"command": "mix.volume", "params": {"track": "Kick", "db": -8.5}})); + tool(&mut s, "execute", json!({"command": "mix.pan", "params": {"track": "Hats", "pan": -0.5}})); + let sess = session(&mut s); + assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5)); + assert_eq!(track(&sess, "Hats")["pan"][0], json!(-0.5)); + + // 3. Put an EQ on the bass with a low-shelf boost, then bypass it. + let r = tool(&mut s, "execute", json!({"command": "mix.insert", "params": {"track": "Bass", "slot": "c", "plugin": "eq_7band"}})); + assert_eq!(r["slot"], 2); + let sess = session(&mut s); + assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().filter(|i| i["plugin"] == "eq_7band").count(), 1); + tool(&mut s, "execute", json!({"command": "mix.insert_bypass", "params": {"track": "Bass", "slot": 2}})); + let sess = session(&mut s); + assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().find(|i| i["slot"] == 2).unwrap()["bypass"], true); + + // 4. New stereo aux fed from a send. + tool( + &mut s, + "batch", + json!({"calls": [ + {"command": "track.new", "params": {"kind": "aux", "format": "stereo", "name": "Delay"}}, + {"command": "track.input", "params": {"track": "Delay", "input": "DelayBus"}}, + {"command": "mix.insert", "params": {"track": "Delay", "plugin": "mod_delay"}}, + {"command": "mix.send", "params": {"track": "Lead", "slot": 1, "bus": "DelayBus", "level_db": -6}}, + ]}), + ); + let sess = session(&mut s); + let lead = track(&sess, "Lead"); + assert_eq!(lead["sends"].as_array().unwrap().iter().find(|x| x["slot"] == 1).unwrap()["level_db"], json!(-6.0)); + + // 5. Edit: select bars 5–9 on Snare, cut, undo, verify the clip count is restored. + let before = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(); + tool( + &mut s, + "execute", + json!({"command": "edit.select", "params": {"tracks": ["Snare"], "start": "bars_beats:5|1|000", "end": "bars_beats:9|1|000"}}), + ); + tool(&mut s, "execute", json!({"command": "edit.cut", "params": {}})); + let mid = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(); + assert_ne!(mid, before); + tool(&mut s, "execute", json!({"command": "edit.undo", "params": {}})); + assert_eq!(track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(), before); + + // 6. Markers and tempo. + tool(&mut s, "execute", json!({"command": "markers.add", "params": {"name": "Bridge", "at": {"seconds": 30.0}}})); + tool(&mut s, "execute", json!({"command": "event.tempo", "params": {"bpm": 110.0}})); + let sess = session(&mut s); + assert!(sess["markers"].as_array().unwrap().iter().any(|m| m["name"] == "Bridge")); + assert_eq!(sess["tempo"][0]["bpm"], json!(110.0)); + + // 7. Volume automation ride on the pad. + tool( + &mut s, + "execute", + json!({"command": "automation.write_range", "params": {"track": "Pad", "param": "volume", "start": {"seconds": 10.0}, "end": {"seconds": 12.0}, "value": -20.0}}), + ); + let pad = track(&session(&mut s), "Pad").clone(); + assert!(pad["automation"].as_array().unwrap().iter().any(|l| l["param"] == "volume")); + + // 8. Bounce 4 seconds and check loudness is sane. + let dir = std::env::temp_dir().join(format!("soundcraft-agent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bounce.wav"); + let r = + tool(&mut s, "bounce_mix", json!({"path": path.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 14.0}, "bit_depth": "24"})); + assert!(r["peak_db"].as_f64().unwrap() > -40.0, "{r}"); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(&bytes[0..4], b"RIFF"); + + // 9. Save, reopen, and the mix survives. + let sp = dir.join("Agent Mix").join("Agent Mix.scraft"); + std::fs::create_dir_all(sp.parent().unwrap()).unwrap(); + tool(&mut s, "save_session", json!({"path": sp.to_string_lossy()})); + tool(&mut s, "new_session", json!({})); + tool(&mut s, "open_session", json!({"path": sp.to_string_lossy()})); + let sess = session(&mut s); + assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5)); + assert!(sess["sources"].as_array().unwrap().len() >= 5); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn errors_are_reported_not_panics() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc(&mut s, 1, "tools/call", json!({"name": "execute", "arguments": {"command": "mix.volume", "params": {"track": "Nope", "db": 0}}})); + assert_eq!(r["result"]["isError"], true); + let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}})); + assert_eq!(r["result"]["isError"], true); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 60bbc98..c7b27de 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -63,7 +63,6 @@ pub const ALIASES: &[(&str, &str)] = &[ ("View > Rulers > Meter", "view.ruler"), ("View > Rulers > Markers", "view.ruler"), ("View > Rulers > Chord Symbols", "view.ruler"), - ("Window > Undo History", "engine.history"), ]; pub fn catalog() -> Vec<&'static str> { diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs new file mode 100644 index 0000000..ac26a30 --- /dev/null +++ b/crates/engine/src/cmd/audiosuite.rs @@ -0,0 +1,124 @@ +//! AudioSuite-style offline processing of selected clips. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_model::{Clip, ClipContent}; + +pub fn specs() -> Vec { + vec![cmd!( + "audiosuite.process", + "AudioSuite Process", + [], + None, + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}", + has_selection, + process + )] +} + +/// Process each target clip's audio and replace the clip with one backed by a new source. +fn process(e: &mut Engine, p: &Value) -> Result { + let name = str_param(p, "process").or_else(|| str_param(p, "plugin")).ok_or_else(|| bad("audiosuite.process", "`process` required"))?.to_string(); + let params: Vec<(String, f32)> = p + .get("params") + .and_then(Value::as_object) + .map(|o| o.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.clone(), x as f32))).collect()) + .unwrap_or_default(); + let get = |k: &str, d: f32| params.iter().find(|(n, _)| n == k).map_or(d, |(_, v)| *v); + let known = soundcraft_dsp::plugin_info(&name).is_some() + || matches!( + name.as_str(), + "normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal" + ); + if !known { + return Err(bad("audiosuite.process", format!("unknown process `{name}`"))); + } + let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("audiosuite.process", "select audio clips first")); + } + let sr = e.session().sample_rate; + let mut done = 0; + for id in ids { + let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; + if !clip.is_audio() { + continue; + } + let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range()); + // Render without the clip's own fades: re-render a copy without fades so they stay editable. + let mut tmp = e.session().clone(); + if let Some(c) = tmp.find_clip_mut(id) { + c.fade_in = Default::default(); + c.fade_out = Default::default(); + c.gain_db = 0.0; + c.gain_env.clear(); + } + if let Some(pl) = tmp.track_mut(track).and_then(|t| t.playlist_mut()) { + pl.clips.retain(|c| c.id == id); + audio = soundcraft_mix::render_clips(&tmp, track, clip.range()); + } + let srf = sr.as_f64() as f32; + let out: Vec> = match name.as_str() { + "normalize" => { + soundcraft_dsp::offline::normalize(&mut audio, get("target_db", -0.1), false); + audio + } + "reverse" => { + soundcraft_dsp::offline::reverse(&mut audio); + audio + } + "invert" => { + soundcraft_dsp::offline::invert(&mut audio); + audio + } + "gain" if soundcraft_dsp::plugin_info("gain").is_none() || params.iter().any(|(k, _)| k == "db") => { + soundcraft_dsp::offline::gain(&mut audio, get("db", 0.0)); + audio + } + "duplicate" => audio, + "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf), + "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf), + "varispeed" => { + let speed = get("speed", 1.0).clamp(0.25, 4.0); + let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32; + soundcraft_dsp::offline::resample(&audio, sr.hz(), to) + } + plugin => { + let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) }; + pl.prepare(srf, 1024, audio.len().max(1)); + for (k, v) in ¶ms { + pl.set_param(k, *v); + } + soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf); + audio + } + }; + let frames = out.first().map_or(0, Vec::len); + if frames == 0 { + continue; + } + let buf = AudioBuffer { sample_rate: sr.hz(), channels: out }; + let new_name = format!("{}-{}", clip.name, short(&name)); + let s = e.session_mut(); + let src = crate::io::add_source(s, &new_name, buf, None, FileFormat::Wav); + let mut nc: Clip = clip.clone(); + nc.name = new_name; + nc.content = ClipContent::Audio { source: src, offset: 0 }; + nc.length = i64::try_from(frames).unwrap_or(nc.length); + nc.gain_db = 0.0; + nc.gain_env.clear(); + if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { + pl.clips.retain(|c| c.id != id); + } + crate::edit::place_clip(s, track, nc); + done += 1; + } + Ok(json!({"processed": done, "process": name})) +} + +fn short(n: &str) -> String { + let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); + s.chars().take(8).collect() +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dc6663b..8fddf3a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ use soundcraft_model::{ClipId, TrackId}; use soundcraft_time::{Range, Samples}; use std::sync::OnceLock; +mod audiosuite; mod clip; mod edit; mod event; @@ -91,6 +92,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(options::specs()); v.extend(transport::specs()); v.extend(query::specs()); + v.extend(audiosuite::specs()); v }) } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 0000000..facbdd3 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "soundcraft-ui-egui" +description = "SoundCraft's egui user interface: Edit and Mix windows, transport, dialogs, control channel" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +soundcraft-time.workspace = true +soundcraft-audio-io.workspace = true +soundcraft-midi.workspace = true +soundcraft-dsp.workspace = true +soundcraft-model.workspace = true +soundcraft-mix.workspace = true +soundcraft-engine.workspace = true +soundcraft-playback.workspace = true +egui.workspace = true +serde.workspace = true +serde_json.workspace = true +log.workspace = true +image.workspace = true + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } + +[lints] +workspace = true diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs new file mode 100644 index 0000000..acb1d5a --- /dev/null +++ b/crates/ui-egui/examples/ui_shot.rs @@ -0,0 +1,100 @@ +//! Render the real UI offscreen (no window, no focus stealing) and save PNGs. +//! +//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]` +//! +//! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}` +//! or `{"steps": n}`. The demo session is loaded unless `--blank`. + +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; + +static READY: AtomicBool = AtomicBool::new(false); + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into()); + let script = args.get(1).filter(|a| !a.starts_with("--")).cloned(); + let blank = args.iter().any(|a| a == "--blank"); + let (w, h) = args + .iter() + .position(|a| a == "--size") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.split_once('x')) + .and_then(|(a, b)| Some((a.parse::().ok()?, b.parse::().ok()?))) + .unwrap_or((1600.0, 1000.0)); + let engine = if blank { soundcraft_engine::Engine::default() } else { soundcraft_engine::demo::demo_engine() }; + let (tx, rx) = mpsc::channel::(); + let app = SoundApp::new(engine, None, Services::default()).with_control(rx); + let mut harness = + egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + if !READY.load(Ordering::Relaxed) { + return; + } + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + READY.store(true, Ordering::Relaxed); + let step = |h: &mut egui_kittest::Harness| { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); + }; + for _ in 0..6 { + step(&mut harness); + } + let shoot = |h: &mut egui_kittest::Harness, path: &str| match h.render() { + Ok(img) => match img.save(path) { + Ok(()) => println!("wrote {path}"), + Err(e) => eprintln!("{path}: {e}"), + }, + Err(e) => eprintln!("render failed: {e}"), + }; + if let Some(script) = script { + let text = std::fs::read_to_string(&script).unwrap_or_default(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let Ok(v) = serde_json::from_str::(line) else { + eprintln!("bad line: {line}"); + continue; + }; + if let Some(p) = v.get("shot").and_then(|x| x.as_str()) { + for _ in 0..3 { + step(&mut harness); + } + shoot(&mut harness, p); + continue; + } + if let Some(n) = v.get("steps").and_then(|x| x.as_u64()) { + for _ in 0..n { + step(&mut harness); + } + continue; + } + let method = v.get("method").and_then(|m| m.as_str()).unwrap_or("").to_string(); + let params = v.get("params").cloned().unwrap_or(serde_json::json!({})); + let (req, reply) = ControlRequest::new(method, params); + let _ = tx.send(req); + for _ in 0..40 { + step(&mut harness); + if let Ok(r) = reply.try_recv() { + let s = r.to_string(); + println!("{}", if s.len() > 300 { &s[..300] } else { &s }); + break; + } + } + for _ in 0..12 { + step(&mut harness); + } + } + } + for _ in 0..4 { + step(&mut harness); + } + shoot(&mut harness, &out); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 0000000..93b0626 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,309 @@ +//! The JSON control channel's UI side. The app's TCP server turns each request line into a +//! [`ControlRequest`]; they are handled on the UI thread here, so every command an agent sends +//! goes through exactly the same code as a click. +//! +//! Methods: +//! - `engine.execute {command, params}` (alias `command`): run any command programmatically (no dialogs) +//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect` +//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs) +//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}` +//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}` +//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}` +//! - `app.quit` + +use crate::SoundApp; +use serde_json::{Value, json}; +use std::sync::mpsc::{Receiver, Sender, channel}; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, Receiver) { + let (tx, rx) = channel(); + (ControlRequest { method: method.into(), params, reply: tx }, rx) + } +} + +pub struct PendingShot { + token: u64, + reply: Sender, + path: Option, + frames: u32, + sent: bool, + deadline_frames: u32, +} + +fn ok(v: Value) -> Value { + json!({"ok": true, "result": v}) +} + +fn err(e: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": e.to_string()}) +} + +pub fn drain(app: &mut SoundApp, ctx: &egui::Context) { + let Some(rx) = app.control_rx.take() else { return }; + let mut n = 0; + while let Ok(req) = rx.try_recv() { + n += 1; + if let Some(v) = handle(app, ctx, &req) { + let _ = req.reply.send(v); + } + if n > 64 { + break; + } + } + app.control_rx = Some(rx); + if n > 0 { + ctx.request_repaint(); + } +} + +fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "space" => Some(egui::Key::Space), + "enter" | "return" => Some(egui::Key::Enter), + "escape" | "esc" => Some(egui::Key::Escape), + "tab" => Some(egui::Key::Tab), + "home" => Some(egui::Key::Home), + "end" => Some(egui::Key::End), + "=" => Some(egui::Key::Equals), + _ => None, + }) +} + +fn mods(p: &Value) -> egui::Modifiers { + let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let cmd = b("cmd") || b("command"); + egui::Modifiers { + alt: b("alt"), + ctrl: b("ctrl") || (cmd && !cfg!(target_os = "macos")), + shift: b("shift"), + mac_cmd: cmd && cfg!(target_os = "macos"), + command: cmd, + } +} + +fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option { + let p = &req.params; + let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + Some(match req.method.as_str() { + "engine.execute" | "command" => { + let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or(""); + let params = p.get("params").cloned().unwrap_or(json!({})); + match app.run(id, params) { + Ok(v) => ok(v), + Err(e) => err(e), + } + } + "engine.commands" => { + let mut list = match app.engine.execute("engine.commands", p) { + Ok(v) => v, + Err(e) => return Some(err(e)), + }; + if let Some(arr) = list.as_array_mut() { + for (id, label, path, sc) in crate::menus::UI_COMMANDS { + arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"})); + } + } + ok(list) + } + "engine.parity" => ok(crate::menus::parity()), + "session.inspect" | "document.inspect" => { + ok(soundcraft_engine::inspect::session(&app.engine, p.get("detail").and_then(Value::as_str) == Some("full"))) + } + "ui.inspect" => { + let mut v = app.inspect(ctx); + v["edit_layout"] = json!(app.edit_layout); + ok(v) + } + "ui.menu.list" => ok(json!( + soundcraft_engine::catalog::catalog() + .iter() + .map(|e| json!({"path": e, "command": soundcraft_engine::catalog::implemented_by(e, &crate::menus::ui_aliases())})) + .collect::>() + )), + "ui.menu.invoke" => { + let path = p.get("path").and_then(Value::as_str).unwrap_or(""); + let id = p + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| soundcraft_engine::catalog::implemented_by(path, &crate::menus::ui_aliases())); + match id { + Some(id) => { + crate::menus::invoke_menu(app, &id, path); + ok(json!({"invoked": id, "dialog": app.dialogs.open_name()})) + } + None => err(format!("menu item `{path}` is not implemented yet")), + } + } + "ui.set" => match app.run("ui.set", p.clone()) { + Ok(v) => ok(v), + Err(e) => err(e), + }, + "ui.move" => { + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + ok(json!({})) + } + "ui.click" => { + let pos = egui::pos2(f("x"), f("y")); + let button = match p.get("button").and_then(Value::as_str) { + Some("right") | Some("secondary") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let m = mods(p); + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3); + app.synthetic.push(egui::Event::PointerMoved(pos)); + for _ in 0..count { + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + } + ok(json!({})) + } + "ui.drag" => { + let a = egui::pos2(f("x"), f("y")); + let b = egui::pos2(f("to_x"), f("to_y")); + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + let m = mods(p); + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + for i in 1..=steps { + let k = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * k)); + } + app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + ok(json!({})) + } + "ui.key" => { + let name = p.get("key").and_then(Value::as_str).unwrap_or(""); + let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) }; + let m = mods(p); + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + ok(json!({})) + } + "ui.text" => { + let t = p.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + app.synthetic.push(egui::Event::Text(t)); + ok(json!({})) + } + "ui.screenshot" => { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let token = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + app.pending_shots.push(PendingShot { + token, + reply: req.reply.clone(), + path: p.get("path").and_then(Value::as_str).map(str::to_string), + frames: 0, + sent: false, + deadline_frames: 600, + }); + ctx.request_repaint(); + return None; + } + "app.quit" => { + app.quit_requested = true; + ok(json!({})) + } + other => err(format!("unknown method `{other}`")), + }) +} + +/// Request and collect viewport screenshots for pending `ui.screenshot` calls. +pub fn collect_screenshots(app: &mut SoundApp, ctx: &egui::Context) { + if app.pending_shots.is_empty() { + return; + } + let images: Vec<(u64, std::sync::Arc)> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|t| (*t, image.clone())) + } + _ => None, + }) + .collect() + }); + let mut keep = Vec::new(); + for mut s in std::mem::take(&mut app.pending_shots) { + if let Some((_, img)) = images.iter().find(|(t, _)| *t == s.token) { + let _ = s.reply.send(encode(img, s.path.as_deref())); + continue; + } + s.frames += 1; + if !s.sent && s.frames >= 3 { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); + s.sent = true; + } + if s.frames > s.deadline_frames { + let _ = s.reply.send(err("no frame was presented (is the window visible?)")); + continue; + } + keep.push(s); + } + app.pending_shots = keep; + ctx.request_repaint(); +} + +pub fn png_bytes(img: &egui::ColorImage) -> Result, String> { + let [w, h] = img.size; + let mut raw = Vec::with_capacity(w * h * 4); + for c in &img.pixels { + raw.extend_from_slice(&c.to_array()); + } + let buf = image::RgbaImage::from_raw(u32::try_from(w).map_err(|e| e.to_string())?, u32::try_from(h).map_err(|e| e.to_string())?, raw) + .ok_or("bad image size")?; + let mut out = std::io::Cursor::new(Vec::new()); + buf.write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(out.into_inner()) +} + +fn encode(img: &egui::ColorImage, path: Option<&str>) -> Value { + let bytes = match png_bytes(img) { + Ok(b) => b, + Err(e) => return err(e), + }; + match path { + Some(p) => match std::fs::write(p, &bytes) { + Ok(()) => ok(json!({"path": p, "width": img.size[0], "height": img.size[1]})), + Err(e) => err(format!("{p}: {e}")), + }, + None => ok(json!({"png_base64": base64(&bytes), "width": img.size[0], "height": img.size[1]})), + } +} + +/// Minimal base64 (standard alphabet) to avoid another dependency. +pub fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for i in 0..4 { + if i <= chunk.len() { + let idx = ((n >> (18 - 6 * i)) & 63) as usize; + out.push(char::from(T.get(idx).copied().unwrap_or(b'A'))); + } else { + out.push('='); + } + } + } + out +} + +#[cfg(test)] +mod tests { + #[test] + fn base64_known() { + assert_eq!(super::base64(b"Man"), "TWFu"); + assert_eq!(super::base64(b"Ma"), "TWE="); + assert_eq!(super::base64(b"M"), "TQ=="); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 0000000..8259fe3 --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,387 @@ +//! Modal dialogs opened by menu-style invocations. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use egui::{Align2, vec2}; +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use soundcraft_model::{ClipId, TrackId}; + +#[derive(Debug, Clone)] +pub enum Dialog { + NewTracks { count: u32, format: String, kind: String, timebase: String, name: String }, + NewSession { name: String, sample_rate: u32, demo: bool }, + Bounce { path: String, format: String, bit_depth: String, normalize: bool }, + RenameTrack { id: TrackId, name: String }, + RenameClip { id: ClipId, name: String }, + PathPrompt { cmd: String, title: String, path: String, key: String }, + Number { cmd: String, title: String, key: String, value: f64, suffix: String }, + Fades { shape: String }, + StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, + Group { name: String }, + Session { frame_rate: String, bit_depth: String }, +} + +#[derive(Default)] +pub struct Dialogs { + pub open: Option, +} + +impl Dialogs { + pub fn open_name(&self) -> Option<&'static str> { + self.open.as_ref().map(|d| match d { + Dialog::NewTracks { .. } => "new_tracks", + Dialog::NewSession { .. } => "new_session", + Dialog::Bounce { .. } => "bounce", + Dialog::RenameTrack { .. } => "rename_track", + Dialog::RenameClip { .. } => "rename_clip", + Dialog::PathPrompt { .. } => "path", + Dialog::Number { .. } => "number", + Dialog::Fades { .. } => "fades", + Dialog::StripSilence { .. } => "strip_silence", + Dialog::Group { .. } => "group", + Dialog::Session { .. } => "session", + }) + } + + pub fn open_rename_track(&mut self, id: TrackId, name: &str) { + self.open = Some(Dialog::RenameTrack { id, name: name.to_string() }); + } + + pub fn open_rename_clip(&mut self, id: ClipId, name: &str) { + self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); + } + + /// Open the dialog for a command; false when the command has no dialog. + pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool { + let home = default_dir(); + let name = e.session().name.clone(); + self.open = Some(match id { + "track.new" => { + Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() } + } + "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, + "file.bounce_mix" => { + Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } + } + "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, + "file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() }, + "session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() }, + "session.save_as" | "session.save_copy" => Dialog::PathPrompt { + cmd: id.into(), + title: "Save Session As".into(), + path: format!("{home}/{name}/{name}.scraft"), + key: "path".into(), + }, + "edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() }, + "edit.shift" => { + Dialog::Number { cmd: id.into(), title: "Shift (seconds)".into(), key: "by_seconds".into(), value: 1.0, suffix: "s".into() } + } + "event.transpose" => { + Dialog::Number { cmd: id.into(), title: "Transpose".into(), key: "semitones".into(), value: 12.0, suffix: "semitones".into() } + } + "event.quantize" => { + Dialog::Number { cmd: id.into(), title: "Quantize strength".into(), key: "strength".into(), value: 100.0, suffix: "%".into() } + } + "edit.fades_create" => Dialog::Fades { shape: "equal power".into() }, + "edit.strip_silence" => Dialog::StripSilence { threshold: -48.0, min_ms: 50.0, pre_ms: 5.0, post_ms: 20.0 }, + "track.group" => Dialog::Group { name: format!("Group {}", e.session().groups.len() + 1) }, + "setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() }, + _ => { + return false; + } + }); + true + } +} + +fn default_dir() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| ".".into()) + } + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// Show the open dialog, if any. +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + audiosuite_window(app, ctx); + let Some(mut d) = app.dialogs.open.take() else { return }; + let mut keep = true; + let mut action: Option<(String, Value)> = None; + let title = match &d { + Dialog::NewTracks { .. } => "New Tracks", + Dialog::NewSession { .. } => "New Session", + Dialog::Bounce { .. } => "Bounce Mix", + Dialog::RenameTrack { .. } => "Rename Track", + Dialog::RenameClip { .. } => "Rename Clip", + Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), + Dialog::Fades { .. } => "Fades", + Dialog::StripSilence { .. } => "Strip Silence", + Dialog::Group { .. } => "Create Group", + Dialog::Session { .. } => "Session Setup", + } + .to_string(); + let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); + let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + egui::Window::new(egui::RichText::new(&title).font(bold(13.0))) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) + .show(ctx, |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + match &mut d { + Dialog::NewTracks { count, format, kind, timebase, name } => { + ui.horizontal(|ui| { + ui.label("Create"); + ui.add(egui::DragValue::new(count).range(1..=128)); + ui.label("new"); + egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { + for f in soundcraft_model::ChannelFormat::ALL { + ui.selectable_value(format, f.label().to_string(), f.label()); + } + }); + egui::ComboBox::from_id_salt("nt_kind") + .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label())) + .show_ui(ui, |ui| { + for k in soundcraft_model::TrackKind::ALL { + if ui.selectable_label(kind == k.id(), k.label()).clicked() { + *kind = k.id().to_string(); + *name = k.default_name().to_string(); + } + } + }); + ui.label("in"); + egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui( + ui, + |ui| { + ui.selectable_value(timebase, "samples".to_string(), "Samples"); + ui.selectable_value(timebase, "ticks".to_string(), "Ticks"); + }, + ); + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); + }); + if buttons(ui, "Create", enter) { + action = + Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); + } + } + Dialog::NewSession { name, sample_rate, demo } => { + ui.horizontal(|ui| { + ui.label("Name"); + ui.text_edit_singleline(name); + }); + egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| { + for r in soundcraft_time::SampleRate::COMMON { + ui.selectable_value(sample_rate, r, format!("{r} Hz")); + } + }); + ui.checkbox(demo, "Start from the demo session"); + if buttons(ui, "Create", enter) { + action = Some(( + "session.new".into(), + json!({"name": name, "sample_rate": sample_rate, "template": if *demo { "demo" } else { "blank" }}), + )); + } + } + Dialog::Bounce { path, format, bit_depth, normalize } => { + ui.horizontal(|ui| { + ui.label("File"); + ui.add(egui::TextEdit::singleline(path).desired_width(360.0)); + }); + ui.horizontal(|ui| { + egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| { + for f in ["wav", "aiff", "flac"] { + ui.selectable_value(format, f.to_string(), f.to_uppercase()); + } + }); + egui::ComboBox::from_label("Bit Depth").selected_text(format!("{bit_depth}-bit")).show_ui(ui, |ui| { + for b in ["16", "24", "32f"] { + ui.selectable_value(bit_depth, b.to_string(), format!("{b}-bit")); + } + }); + }); + ui.checkbox(normalize, "Normalize to -0.1 dBFS"); + ui.label( + egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.") + .small() + .color(Tokens::DARK.text_dim), + ); + if buttons(ui, "Bounce", enter) { + let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); + action = Some(( + "file.bounce_mix".into(), + json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), + )); + } + } + Dialog::RenameTrack { id, name } => { + let r = ui.text_edit_singleline(name); + r.request_focus(); + if buttons(ui, "OK", enter) { + action = Some(("track.rename".into(), json!({"track": id.0, "name": name}))); + } + } + Dialog::RenameClip { id, name } => { + let r = ui.text_edit_singleline(name); + r.request_focus(); + if buttons(ui, "OK", enter) { + action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); + } + } + Dialog::PathPrompt { cmd, path, key, .. } => { + ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + if let Some(pick) = &app.services.pick_open + && cmd != "session.save_as" + && cmd != "session.save_copy" + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, &[]) + { + *path = p; + } + if let Some(pick) = &app.services.pick_save + && (cmd == "session.save_as" || cmd == "session.save_copy") + && ui.button("Browse…").clicked() + && let Some(p) = pick(cmd, path) + { + *path = p; + } + if buttons(ui, "OK", enter) { + let mut v = json!({}); + v[key.as_str()] = json!(path); + action = Some((cmd.clone(), v)); + } + } + Dialog::Number { cmd, key, value, suffix, .. } => { + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(value).speed(0.1)); + ui.label(suffix.as_str()); + }); + if buttons(ui, "OK", enter) { + let v = if key == "by_seconds" { json!({"by": {"seconds": value}}) } else { json!({key.as_str(): value}) }; + action = Some((cmd.clone(), v)); + } + } + Dialog::Fades { shape } => { + for s in soundcraft_model::FadeShape::ALL { + ui.radio_value(shape, s.label().to_lowercase(), s.label()); + } + if buttons(ui, "OK", enter) { + action = Some(("edit.fades_create".into(), json!({"shape": shape}))); + } + } + Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => { + ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB")); + ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms")); + ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms")); + ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms")); + if buttons(ui, "Strip", enter) { + action = Some(( + "edit.strip_silence".into(), + json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}), + )); + } + } + Dialog::Group { name } => { + ui.text_edit_singleline(name); + if buttons(ui, "OK", enter) { + action = Some(("track.group".into(), json!({"name": name}))); + } + } + Dialog::Session { frame_rate, bit_depth } => { + egui::ComboBox::from_label("Timecode Rate").selected_text(frame_rate.as_str()).show_ui(ui, |ui| { + for r in soundcraft_time::FrameRate::ALL { + ui.selectable_value(frame_rate, r.label().to_string(), r.label()); + } + }); + egui::ComboBox::from_label("Bit Depth").selected_text(bit_depth.as_str()).show_ui(ui, |ui| { + for b in ["16", "24", "32f"] { + ui.selectable_value(bit_depth, b.to_string(), b); + } + }); + if buttons(ui, "OK", enter) { + action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth}))); + } + } + } + }); + if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::new("dlg_cancel")).unwrap_or(false)) { + keep = false; + ctx.memory_mut(|m| m.data.remove::(egui::Id::new("dlg_cancel"))); + } + if let Some((cmd, params)) = action { + keep = false; + if let Err(e) = app.run(&cmd, params) { + app.ui.status = e; + } + } + if keep { + app.dialogs.open = Some(d); + } +} + +/// Cancel/OK row; returns true when OK was pressed (or Enter). +fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { + let mut pressed = enter; + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true)); + } + if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::DARK.accent)).clicked() { + pressed = true; + } + }); + pressed +} + +fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { + let Some(process) = app.ui.audiosuite.clone() else { return }; + let mut open = true; + let title = soundcraft_dsp::plugin_info(&process).map_or_else(|| process.replace('_', " "), |p| p.name.to_string()); + egui::Window::new(format!("AudioSuite · {title}")).open(&mut open).default_width(360.0).show(ctx, |ui| { + let key = egui::Id::new(("as_params", process.clone())); + let mut params: std::collections::BTreeMap = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_default(); + if let Some(info) = soundcraft_dsp::plugin_info(&process) { + for p in info.params { + let v = params.entry(p.id.to_string()).or_insert(p.default); + ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name)); + } + } else { + match process.as_str() { + "normalize" => { + let v = params.entry("target_db".into()).or_insert(-0.1); + ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB")); + } + "time_stretch" => { + let v = params.entry("ratio".into()).or_insert(1.0); + ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio")); + } + "pitch_shift" => { + let v = params.entry("semitones".into()).or_insert(0.0); + ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones")); + } + "varispeed" => { + let v = params.entry("speed".into()).or_insert(1.0); + ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed")); + } + _ => { + ui.label("No parameters."); + } + } + } + ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone())); + ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small()); + if ui.button(egui::RichText::new("Render").strong()).clicked() { + let p: serde_json::Map = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); + let _ = app.run("audiosuite.process", json!({"process": process, "params": p})); + } + }); + if !open { + app.ui.audiosuite = None; + } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs new file mode 100644 index 0000000..bb43db7 --- /dev/null +++ b/crates/ui-egui/src/edit_window.rs @@ -0,0 +1,1228 @@ +//! The Edit window: toolbar, rulers, track headers and playlists. + +use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; +use crate::widgets::{rec_toggle, selector_box, text_toggle}; +use crate::{Gesture, SoundApp, icons, panels, toolbar}; +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; +use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; + +pub const HEADER_W: f32 = 236.0; +pub const RULER_H: f32 = 17.0; + +/// Geometry of the last frame, for hit testing and agents (`ui.inspect`). +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct EditLayout { + pub timeline: [f32; 4], + pub rows: Vec<(u64, [f32; 4])>, + pub scroll_y: f32, + pub content_h: f32, +} + +pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { + tl.min.x + ((at - s.edit.zoom.scroll) as f64 / s.edit.zoom.samples_per_px.max(0.01)) as f32 +} + +pub fn sample_at(s: &Session, tl: Rect, x: f32) -> Samples { + s.edit.zoom.scroll + soundcraft_time::to_samples(f64::from(x - tl.min.x) * s.edit.zoom.samples_per_px) +} + +pub fn track_height(t: &Track) -> f32 { + t.height.points() +} + +pub fn show(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); + egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); + if app.ui.show_tracks_list { + egui::Panel::left("tracks_list") + .exact_size(168.0) + .frame(egui::Frame::NONE.fill(t.panel_bg)) + .show(ui, |ui| panels::tracks_and_groups(app, ui)); + } + if app.ui.show_clip_list { + egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui)); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); +} + +fn status_bar(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let r = ui.max_rect(); + let s = app.engine.session(); + let left = format!( + "{} · {} Hz · {} · {} tracks · {}", + s.name, + s.sample_rate.hz(), + match s.bit_depth { + soundcraft_model::BitDepthSetting::Int16 => "16-bit", + soundcraft_model::BitDepthSetting::Int24 => "24-bit", + soundcraft_model::BitDepthSetting::Float32 => "32-bit float", + }, + s.tracks.len(), + app.ui.status + ); + ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, left, regular(11.0), t.text_dim); + let undo = app.engine.undo_label().map_or(String::new(), |l| format!("Undo {l}")); + ui.painter().text( + pos2(r.max.x - 10.0, r.center().y), + Align2::RIGHT_CENTER, + format!("{undo} {:.1} ms/frame", app.frame_ms), + regular(11.0), + t.text_dim, + ); +} + +fn main_area(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let full = ui.max_rect(); + let rulers: Vec = app.engine.session().edit.rulers.clone(); + let rulers_h = RULER_H * rulers.len() as f32 + 6.0; + let hscroll_h = 14.0; + let header = Rect::from_min_max(full.min, pos2(full.min.x + HEADER_W, full.max.y - hscroll_h)); + let tl = Rect::from_min_max(pos2(full.min.x + HEADER_W, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h)); + let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); + app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; + ui.painter().rect_filled(full, 0.0, t.window_bg); + // Wheel: vertical scroll tracks, shift/horizontal scroll timeline, cmd = zoom. + let hovered = ui.rect_contains_pointer(Rect::from_min_max(pos2(full.min.x, tl.min.y), full.max)); + if hovered { + let (delta, mods) = ui.input(|i| (i.smooth_scroll_delta, i.modifiers)); + if mods.command && delta.y.abs() > 0.0 { + let id = if delta.y > 0.0 { "view.zoom_in" } else { "view.zoom_out" }; + let _ = app.run(id, json!({})); + } else { + let dx = if mods.shift { delta.y } else { delta.x }; + if dx.abs() > 0.0 { + let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); + } + if !mods.shift && delta.y.abs() > 0.0 { + app.edit_layout.scroll_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0)); + } + } + } + draw_rulers(app, ui, rulers_rect, tl, &rulers); + // Tracks. + let ids: Vec = + app.engine.session().tracks.iter().filter(|x| !x.hidden && !folder_collapsed(app.engine.session(), x)).map(|x| x.id).collect(); + let mut y = tl.min.y - app.edit_layout.scroll_y; + app.edit_layout.rows.clear(); + let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y)); + let mut content_h = 0.0; + for id in ids { + let Some(h) = app.engine.session().track(id).map(track_height) else { continue }; + let row = Rect::from_min_max(pos2(full.min.x, y), pos2(tl.max.x, y + h)); + if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); + child.set_clip_rect(clip_rect); + track_row(app, &mut child, id, row, tl); + } + app.edit_layout.rows.push((id.0, [row.min.x, row.min.y, row.max.x, row.max.y])); + y += h + 1.0; + content_h += h + 1.0; + } + app.edit_layout.content_h = content_h; + // Empty area click clears track selection. + let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); + if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { + let _ = app.run("edit.select_none", json!({})); + } + overlay(app, ui, tl, Rect::from_min_max(pos2(tl.min.x, rulers_rect.min.y), tl.max)); + let _ = header; + // Horizontal scrollbar. + let sb = Rect::from_min_max(pos2(tl.min.x, full.max.y - hscroll_h), pos2(tl.max.x, full.max.y)); + hscrollbar(app, ui, sb, tl); + // Vertical scrollbar gutter. + let vs = Rect::from_min_max(pos2(tl.max.x, tl.min.y), pos2(full.max.x, tl.max.y)); + ui.painter().rect_filled(vs, 0.0, t.panel_bg); + if content_h > tl.height() { + let frac = tl.height() / content_h; + let off = app.edit_layout.scroll_y / content_h; + let thumb = Rect::from_min_size(pos2(vs.min.x + 2.0, vs.min.y + vs.height() * off), vec2(vs.width() - 4.0, (vs.height() * frac).max(20.0))); + ui.painter().rect_filled(thumb, 3.0, t.button_hi); + let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag()); + if resp.dragged() { + app.edit_layout.scroll_y = (app.edit_layout.scroll_y + resp.drag_delta().y / frac).clamp(0.0, (content_h - tl.height() + 40.0).max(0.0)); + } + } +} + +fn folder_collapsed(s: &Session, t: &Track) -> bool { + t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open) +} + +fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { + let t = Tokens::DARK; + ui.painter().rect_filled(sb, 0.0, t.panel_bg); + let s = app.engine.session(); + let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64; + let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px; + let frac = (vis / total).clamp(0.02, 1.0) as f32; + let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; + let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); + ui.painter().rect_filled(thumb, 3.0, t.button_hi); + let resp = ui.interact(sb, ui.id().with("hscroll"), Sense::drag()); + if resp.dragged() { + let dx = resp.drag_delta().x / sb.width(); + let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px; + let _ = app.engine.execute("view.scroll", &json!({"by_px": by_px})); + } +} + +// ---- rulers ---------------------------------------------------------------------------------- + +fn ruler_label(id: &str) -> &'static str { + match id { + "bars_beats" => "Bars|Beats", + "min_secs" => "Min:Secs", + "timecode" => "Timecode", + "feet_frames" => "Feet+Frames", + "samples" => "Samples", + "tempo" => "Tempo", + "meter" => "Meter", + "markers" => "Markers", + "key" => "Key", + "chords" => "Chords", + _ => "", + } +} + +/// A "nice" step in samples for ruler labels at the current zoom. +fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 { + let min = spp * min_px; + let steps_s = [0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1800.0, 3600.0]; + for st in steps_s { + if st * sr >= min { + return (st * sr) as i64; + } + } + (3600.0 * sr) as i64 +} + +fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) { + let t = Tokens::DARK; + let s = app.engine.session().clone(); + ui.painter().rect_filled(area, 0.0, t.ruler_bg); + let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); + let sr = s.sample_rate; + for (i, id) in rulers.iter().enumerate() { + let y0 = area.min.y + 3.0 + i as f32 * RULER_H; + let row = Rect::from_min_max(pos2(tl.min.x, y0), pos2(tl.max.x, y0 + RULER_H)); + let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(tl.min.x, y0 + RULER_H)); + let painter = ui.painter().with_clip_rect(row); + ui.painter().text(pos2(lab.max.x - 12.0, lab.center().y), Align2::RIGHT_CENTER, ruler_label(id), bold(11.0), t.ruler_text); + let fmt = TimeFormat::from_id(id); + match id.as_str() { + "tempo" => { + painter.rect_filled(row.shrink2(vec2(0.0, 1.0)), 0.0, t.tempo_ruler); + for ev in s.tempo.tempos() { + let x = x_of(&s, tl, s.tempo.tick_to_samples(ev.tick, sr)); + painter.add(Shape::convex_polygon( + vec![pos2(x, row.min.y + 3.0), pos2(x + 7.0, row.center().y), pos2(x, row.max.y - 3.0)], + Color32::from_rgb(220, 60, 50), + Stroke::NONE, + )); + painter.text( + pos2(x + 10.0, row.center().y), + Align2::LEFT_CENTER, + format!("{}", (ev.bpm * 100.0).round() / 100.0), + bold(11.0), + Color32::from_rgb(230, 240, 230), + ); + } + } + "meter" => { + painter.rect_filled(row.shrink2(vec2(0.0, 1.0)), 0.0, t.meter_ruler); + for ev in s.tempo.meters() { + let x = x_of(&s, tl, s.tempo.tick_to_samples(ev.tick, sr)); + painter.text( + pos2(x + 6.0, row.center().y), + Align2::LEFT_CENTER, + format!("{}/{}", ev.numerator, ev.denominator), + bold(11.0), + Color32::from_rgb(230, 236, 240), + ); + } + } + "markers" => { + painter.rect_filled(row, 0.0, t.marker_ruler); + for m in &s.markers { + let x = x_of(&s, tl, m.start); + let col = m.color.map_or(Color32::from_rgb(232, 200, 64), rgb); + if m.kind == soundcraft_model::MarkerKind::Selection { + let x1 = x_of(&s, tl, m.end); + painter.rect_filled(Rect::from_min_max(pos2(x, row.min.y + 3.0), pos2(x1, row.max.y - 3.0)), 2.0, col.gamma_multiply(0.5)); + } + painter.add(Shape::convex_polygon( + vec![ + pos2(x - 5.0, row.min.y + 2.0), + pos2(x + 5.0, row.min.y + 2.0), + pos2(x + 5.0, row.max.y - 6.0), + pos2(x, row.max.y - 2.0), + pos2(x - 5.0, row.max.y - 6.0), + ], + col, + Stroke::NONE, + )); + painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), Color32::from_rgb(236, 236, 236)); + } + // Click on the marker ruler: recall a marker; double-click adds one. + let resp = ui.interact(row, ui.id().with("marker_ruler"), Sense::click()); + if let Some(p) = resp.interact_pointer_pos() { + if resp.double_clicked() { + let at = sample_at(&s, tl, p.x).max(0); + let _ = app.run("markers.add", json!({"at": at})); + } else if resp.clicked() + && let Some(m) = s.markers.iter().find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0) + { + let _ = app.run("markers.recall", json!({"number": m.number})); + } + } + } + _ => { + if let Some(f) = fmt { + timebase_ruler(ui, &painter, &s, tl, row, view, f, i + 1 == rulers.len()); + // Clicking a timebase ruler locates. + let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); + if (resp.clicked() || resp.dragged()) + && let Some(p) = resp.interact_pointer_pos() + { + let at = sample_at(&s, tl, p.x).max(0); + let _ = app.run("transport.locate", json!({"at": at})); + } + } + } + } + if id == "min_secs" || id == "bars_beats" || id == "timecode" || id == "samples" || id == "feet_frames" { + // Main timebase marker (blue tick like a selected ruler). + if Some(s.edit.main_counter) == fmt { + ui.painter().rect_filled(Rect::from_min_size(pos2(lab.min.x + 6.0, lab.center().y - 3.0), vec2(6.0, 6.0)), 1.0, t.accent); + } + } + } + ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); +} + +fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: Rect, view: Range, f: TimeFormat, bottom: bool) { + let t = Tokens::DARK; + let sr = s.sample_rate; + let spp = s.edit.zoom.samples_per_px; + let mut marks: Vec<(Samples, bool)> = Vec::new(); + match f { + TimeFormat::BarsBeats => { + // Bars every n so labels are ≥ 60 px apart. + let bar_px = { + let b = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; + b.step_samples(view.start.max(0), sr, &s.tempo, s.frame_rate) as f64 / spp + }; + let every = [1i64, 2, 4, 8, 16, 32, 64, 128].into_iter().find(|n| bar_px * *n as f64 >= 60.0).unwrap_or(256); + let first = s.tempo.bar_beat_at(view.start.max(0), sr).bar; + let mut bar = first - (first - 1).rem_euclid(every); + let mut guard = 0; + loop { + let at = s.tempo.samples_at_bar_beat(soundcraft_time::BarBeat { bar, beat: 1, tick: 0 }, sr); + if at > view.end || guard > 400 { + break; + } + marks.push((at, true)); + if bar_px * every as f64 >= 240.0 { + let m = s.tempo.meter_at_tick(s.tempo.samples_to_ticks(at, sr)); + for beat in 2..=i64::from(m.numerator) { + marks.push((s.tempo.samples_at_bar_beat(soundcraft_time::BarBeat { bar, beat, tick: 0 }, sr), false)); + } + } + bar += every; + guard += 1; + } + } + TimeFormat::Samples => { + let step = { + let min = spp * 90.0; + let mut st = 1i64; + while (st as f64) < min { + st *= if st.to_string().starts_with('1') { + 2 + } else if st.to_string().starts_with('2') { + 5 + } else { + 2 + }; + if st > 10_000_000_000 { + break; + } + } + st + }; + let mut at = view.start.max(0).div_euclid(step) * step; + while at <= view.end && marks.len() < 400 { + marks.push((at, true)); + at += step; + } + } + _ => { + let step = nice_step(spp, sr.as_f64(), 70.0).max(1); + let mut at = view.start.max(0).div_euclid(step) * step; + while at <= view.end && marks.len() < 400 { + marks.push((at, true)); + let sub = step / 4; + if sub > 0 && (sub as f64 / spp) > 8.0 { + for k in 1..4 { + marks.push((at + sub * k, false)); + } + } + at += step; + } + } + } + for (at, major) in marks { + let x = x_of(s, tl, at); + if major { + painter.line_segment([pos2(x, row.min.y + 2.0), pos2(x, row.max.y)], Stroke::new(1.0, t.ruler_tick)); + let label = match f { + TimeFormat::BarsBeats => s.tempo.bar_beat_at(at, sr).bar.to_string(), + TimeFormat::MinSecs => { + let secs = sr.seconds(at); + if secs.fract().abs() < 1e-9 { + format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) + } else { + format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start) + } + } + _ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start), + }; + painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text); + } else { + painter.line_segment([pos2(x, row.max.y - 4.0), pos2(x, row.max.y)], Stroke::new(1.0, t.ruler_tick)); + } + } + if bottom { + painter.line_segment([pos2(tl.min.x, row.max.y), pos2(tl.max.x, row.max.y)], Stroke::new(1.0, t.border)); + } +} + +// ---- track rows ------------------------------------------------------------------------------ + +fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { + let t = Tokens::DARK; + let s = app.engine.session(); + let Some(track) = s.track(id).cloned() else { return }; + let selected = s.edit.selected_tracks.contains(&id); + let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y)); + let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.max.y)); + // Header background. + ui.painter().rect_filled(head, 0.0, if selected { Color32::from_rgb(52, 58, 66) } else { t.panel_bg2 }); + ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); + ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); + track_header(app, ui, &track, head, selected); + // Lane. + let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); + painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); + let s = app.engine.session().clone(); + grid_lines(&painter, &s, tl, lane); + let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); + let auto_view = AutoParam::parse(&track.view); + for clip in track.clips() { + if !clip.range().overlaps(&view) { + continue; + } + draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some()); + } + if let Some(p) = &auto_view { + draw_automation(&painter, &s, &track, p, tl, lane); + } + if track.kind == TrackKind::Master || track.kind == TrackKind::Aux || track.kind == TrackKind::Vca { + let p = auto_view.clone().unwrap_or(AutoParam::Volume); + draw_automation(&painter, &s, &track, &p, tl, lane); + } + // Selection highlight on this track. + let sel = s.edit.selection; + if selected && !sel.is_empty() { + let x0 = x_of(&s, tl, sel.start).max(lane.min.x); + let x1 = x_of(&s, tl, sel.end).min(lane.max.x); + if x1 > x0 { + painter.rect_filled(Rect::from_min_max(pos2(x0, lane.min.y), pos2(x1, lane.max.y)), 0.0, t.selection); + } + } + lane_interaction(app, ui, &track, lane, tl); +} + +fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { + let t = Tokens::DARK; + let id = track.id; + let x0 = head.min.x + 12.0; + let h = head.height(); + // Click on header background selects the track. + let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); + if bg.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + let mut tracks = if add { app.engine.session().edit.selected_tracks.clone() } else { Vec::new() }; + if add && tracks.contains(&id) { + tracks.retain(|x| *x != id); + } else { + tracks.push(id); + } + let ids: Vec = tracks.iter().map(|x| x.0).collect(); + let _ = app.run("edit.select", json!({"tracks": ids})); + } + bg.context_menu(|ui| track_context_menu(app, ui, id)); + // Name field. + let name_r = Rect::from_min_size(pos2(x0, head.min.y + 4.0), vec2(150.0, 17.0)); + ui.painter().rect( + name_r, + CornerRadius::same(2), + if selected { t.name_field_sel } else { t.name_field }, + Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), + StrokeKind::Inside, + ); + ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); + let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click()); + if nresp.double_clicked() { + app.dialogs.open_rename_track(id, &track.name); + } else if nresp.clicked() { + let _ = app.run("edit.select", json!({"tracks": [id.0]})); + } + // Playlist selector arrow. + let pl_r = Rect::from_min_size(pos2(name_r.max.x + 3.0, name_r.min.y), vec2(16.0, 17.0)); + icons::draw(ui.painter(), pl_r.shrink(3.0), "triangle_down", t.text); + let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click()); + egui::Popup::menu(&presp).show(|ui| { + if ui.button("New...").clicked() { + let _ = app.run("track.playlist_new", json!({"track": id.0})); + } + if ui.button("Duplicate...").clicked() { + let _ = app.run("track.playlist_duplicate", json!({"track": id.0})); + } + if ui.button("Delete Unused...").clicked() { + let _ = app.run("track.playlist_delete_unused", json!({"track": id.0})); + } + ui.separator(); + for (i, p) in track.playlists.iter().enumerate() { + if ui.selectable_label(i == track.active_playlist, &p.name).clicked() { + let _ = app.run("track.playlist_select", json!({"track": id.0, "index": i})); + } + } + }); + let kind_label = match track.kind { + TrackKind::Audio => "", + TrackKind::Aux => "AUX", + TrackKind::Master => "MASTER", + TrackKind::Midi => "MIDI", + TrackKind::Instrument => "INST", + TrackKind::Vca => "VCA", + TrackKind::Folder => "FOLDER", + }; + if !kind_label.is_empty() { + ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim); + } + if h < 30.0 { + return; + } + // Buttons row. + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_size(pos2(x0, name_r.max.y + 5.0), vec2(170.0, 20.0))) + .layout(egui::Layout::left_to_right(egui::Align::Min)), + ); + child.spacing_mut().item_spacing.x = 4.0; + let m = &track.mixer; + let bs = vec2(26.0, 18.0); + if track.kind.has_playlist() { + if rec_toggle(&mut child, bs, m.record_arm, "Record enable").clicked() { + let _ = app.run("mix.record_arm", json!({"track": id.0})); + } + if text_toggle(&mut child, bs, "I", m.input_monitor, t.input, "Input monitoring").clicked() { + let _ = app.run("mix.input_monitor", json!({"track": id.0})); + } + } else { + child.add_space(bs.x * 2.0 + 4.0); + } + if track.kind != TrackKind::Master { + if text_toggle(&mut child, bs, "S", m.solo, t.solo, "Solo").clicked() { + let _ = app.run("mix.solo", json!({"track": id.0})); + } + if text_toggle(&mut child, bs, "M", m.mute, t.mute, "Mute").clicked() { + let _ = app.run("mix.mute", json!({"track": id.0})); + } + } + if h < 60.0 { + return; + } + // View selector. + let view_r = Rect::from_min_size(pos2(x0, head.min.y + 49.0), vec2(118.0, 16.0)); + let mut c2 = ui.new_child(egui::UiBuilder::new().max_rect(view_r)); + let vresp = selector_box(&mut c2, view_r.width(), view_r.height(), &view_label(&track.view), t.text); + egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); + // Automation mode. + let am_r = Rect::from_min_size(pos2(x0, head.min.y + 68.0), vec2(118.0, 16.0)); + let mode = m.automation_mode; + let col = match mode { + soundcraft_model::AutomationMode::Read => t.auto_read, + soundcraft_model::AutomationMode::Write => t.auto_write, + soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, + soundcraft_model::AutomationMode::Latch => t.auto_latch, + _ => t.text_dim, + }; + let mut c3 = ui.new_child(egui::UiBuilder::new().max_rect(am_r)); + let aresp = selector_box(&mut c3, am_r.width(), am_r.height(), mode.label(), col); + egui::Popup::menu(&aresp).show(|ui| { + for m in soundcraft_model::AutomationMode::ALL { + if ui.selectable_label(m == mode, m.label()).clicked() { + let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + } + } + }); + // Inline header meter. + let md = app.meters.get(&id).copied().unwrap_or_default(); + let mr = Rect::from_min_max(pos2(head.max.x - 16.0, head.min.y + 4.0), pos2(head.max.x - 6.0, head.max.y - 4.0)); + if track.channels() >= 2 { + let w = mr.width() / 2.0 - 0.5; + crate::widgets::meter(ui, Rect::from_min_size(mr.min, vec2(w, mr.height())), md.level[0], md.hold[0], md.clip); + crate::widgets::meter(ui, Rect::from_min_size(pos2(mr.min.x + w + 1.0, mr.min.y), vec2(w, mr.height())), md.level[1], md.hold[1], md.clip); + } else { + crate::widgets::meter(ui, mr, md.level[0], md.hold[0], md.clip); + } + // Volume readout and inserts summary. + let info = format!("vol {} pan {}", crate::widgets::db_text(m.volume_db), m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p))); + ui.painter().text(pos2(view_r.max.x + 8.0, view_r.center().y), Align2::LEFT_CENTER, info, regular(10.0), t.text_dim); + let ins: Vec<&str> = m.inserts.iter().flatten().filter_map(|i| soundcraft_dsp::plugin_info(&i.plugin).map(|p| p.short_name)).collect(); + if !ins.is_empty() { + ui.painter().with_clip_rect(Rect::from_min_max(pos2(am_r.max.x + 6.0, am_r.min.y), pos2(head.max.x - 20.0, am_r.max.y))).text( + pos2(am_r.max.x + 8.0, am_r.center().y), + Align2::LEFT_CENTER, + ins.join(" · "), + regular(10.0), + t.text_dim, + ); + } +} + +fn view_label(v: &str) -> String { + match AutoParam::parse(v) { + Some(p) => p.label(), + None => v.to_string(), + } +} + +fn view_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track) { + let id = track.id.0; + let mut opts: Vec<&str> = Vec::new(); + if track.kind.is_midi() { + opts.extend(["notes", "regions", "blocks"]); + } else if track.kind.has_playlist() { + opts.extend(["waveform", "blocks", "clip_gain"]); + } + opts.extend(["volume", "pan", "mute"]); + for o in opts { + if ui.selectable_label(track.view == o, view_label(o)).clicked() { + let _ = app.run("track.view", json!({"track": id, "view": o})); + } + } + for (slot, ins) in track.mixer.inserts.iter().enumerate() { + let Some(ins) = ins else { continue }; + let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) else { continue }; + ui.menu_button(info.name, |ui| { + for p in info.params { + let v = format!("plugin:{}:{}", (b'a' + slot as u8) as char, p.id); + if ui.button(p.name).clicked() { + let _ = app.run("track.view", json!({"track": id, "view": v})); + } + } + }); + } +} + +fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { + let tid = id.0; + let items: [(&str, &str); 10] = [ + ("Rename...", "ui.rename_track"), + ("Duplicate...", "track.duplicate"), + ("Make Inactive", "track.make_inactive"), + ("Hide", "track.hide"), + ("Delete", "track.delete"), + ("Split into Mono", "track.split_into_mono"), + ("Commit...", "track.commit"), + ("Freeze", "track.freeze"), + ("Move to New Folder...", "track.move_to_new_folder"), + ("Group...", "track.group"), + ]; + for (label, cmd) in items { + if ui.button(label).clicked() { + if cmd == "ui.rename_track" { + let name = app.engine.session().track(id).map(|t| t.name.clone()).unwrap_or_default(); + app.dialogs.open_rename_track(id, &name); + } else { + let _ = app.run(cmd, json!({"tracks": [tid]})); + } + } + } + ui.separator(); + ui.menu_button("Track Height", |ui| { + for h in TrackHeight::ALL { + if ui.button(h.label()).clicked() { + let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()})); + } + } + }); + ui.menu_button("Track Color", |ui| { + for (i, c) in soundcraft_model::TRACK_COLORS.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(120.0, 14.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, rgb(*c)); + if resp.clicked() { + let _ = app.run("track.color", json!({"tracks": [tid], "color": i})); + } + } + }); +} + +fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { + let t = Tokens::DARK; + let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x)); + let sr = s.sample_rate; + // Bar lines always; grid lines when enabled and dense enough. + let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; + let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; + if bar_px > 6.0 { + for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) { + let x = x_of(s, tl, at); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.bar_line)); + } + } + if s.edit.grid_lines { + let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; + if step_px > 7.0 { + for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) { + let x = x_of(s, tl, at); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.grid_line)); + } + } + } +} + +fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { + let x0 = x_of(s, tl, clip.start); + let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); + let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0)); + let selected = s.edit.selected_clips.contains(&clip.id) + || (s.edit.selected_tracks.contains(&track.id) + && !s.edit.selection.is_empty() + && s.edit.selection.start <= clip.start + && s.edit.selection.end >= clip.end()); + let color = clip.color.unwrap_or(track.color); + let (mut body, bar, mut wave) = clip_colors(color, selected); + if clip.muted || dim { + body = body.gamma_multiply(0.45); + wave = Color32::from_rgb(70, 70, 70); + } + painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + let name_h = if lane.height() >= 40.0 { 13.0 } else { 0.0 }; + if name_h > 0.0 { + let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); + painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); + let name_clip = nb.intersect(lane); + painter.with_clip_rect(name_clip).text( + pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y), + Align2::LEFT_CENTER, + &clip.name, + bold(10.5), + Color32::from_rgb(12, 12, 12), + ); + } + let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); + let vis = body_r.intersect(lane); + match &clip.content { + ClipContent::Audio { source, offset } => { + if let Some(audio) = s.pool.get(*source) { + let nch = track.channels().min(audio.peaks.len().max(1)).max(1); + let ch_h = body_r.height() / nch as f32; + let zoom = s.edit.zoom.waveform_zoom; + let cols = vis.width().max(0.0) as usize; + if cols > 0 && vis.height() > 2.0 { + let a = (offset + sample_at(s, tl, vis.min.x) - clip.start) as f64; + let b = (offset + sample_at(s, tl, vis.max.x) - clip.start) as f64; + for ch in 0..nch { + let Some(pk) = audio.peaks.get(ch % audio.peaks.len().max(1)) else { continue }; + let cy = body_r.min.y + ch_h * (ch as f32 + 0.5); + let half = ch_h * 0.46; + let colsv = pk.columns(a, b, cols); + let mut mesh = egui::Mesh::default(); + for (i, (mn, mx)) in colsv.iter().enumerate() { + let x = vis.min.x + i as f32; + let rel = sample_at(s, tl, x) - clip.start; + let g = clip.gain_at(rel.clamp(0, clip.length)); + let top = cy - (mx * g * zoom).clamp(-1.0, 1.0) * half; + let bot = cy - (mn * g * zoom).clamp(-1.0, 1.0) * half; + let bot = if (bot - top) < 1.0 { top + 1.0 } else { bot }; + let idx = mesh.vertices.len() as u32; + mesh.colored_vertex(pos2(x, top), wave); + mesh.colored_vertex(pos2(x + 1.0, top), wave); + mesh.colored_vertex(pos2(x + 1.0, bot), wave); + mesh.colored_vertex(pos2(x, bot), wave); + mesh.add_triangle(idx, idx + 1, idx + 2); + mesh.add_triangle(idx, idx + 2, idx + 3); + } + painter.with_clip_rect(vis).add(Shape::mesh(mesh)); + if nch > 1 && ch + 1 < nch { + let y = body_r.min.y + ch_h * (ch as f32 + 1.0); + painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); + } + } + } + } else { + painter.text(body_r.center(), Align2::CENTER_CENTER, "media offline", regular(10.0), Color32::from_rgb(220, 120, 120)); + } + } + ClipContent::Midi { sequence } => { + let (lo, hi) = sequence.notes.iter().fold((127u8, 0u8), |(lo, hi), n| (lo.min(n.pitch), hi.max(n.pitch))); + let (lo, hi) = if lo > hi { (48, 72) } else { (lo.saturating_sub(2), hi.saturating_add(2)) }; + let span = f32::from(hi.saturating_sub(lo).max(1)); + let base = s.tempo.samples_to_ticks(clip.start, s.sample_rate); + let p = painter.with_clip_rect(vis); + for n in &sequence.notes { + let a = s.tempo.tick_to_samples(base + n.start, s.sample_rate); + let b = s.tempo.tick_to_samples(base + n.start + n.length, s.sample_rate).min(clip.end()); + let nx0 = x_of(s, tl, a); + let nx1 = x_of(s, tl, b).max(nx0 + 2.0); + if nx1 < vis.min.x || nx0 > vis.max.x { + continue; + } + let y = body_r.max.y - 3.0 - (f32::from(n.pitch.saturating_sub(lo)) / span) * (body_r.height() - 6.0); + let nh = ((body_r.height() - 6.0) / span).clamp(1.5, 6.0); + let vcol = Color32::from_rgb(20, 20 + n.velocity, 40); + p.rect_filled( + Rect::from_min_max(pos2(nx0, y - nh * 0.5), pos2(nx1, y + nh * 0.5)), + 1.0, + if selected { Color32::from_rgb(16, 16, 16) } else { vcol }, + ); + } + } + } + // Fades. + let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); + if clip.fade_in.len > 0 { + let fx = x_of(s, tl, clip.start + clip.fade_in.len); + let pts: Vec = (0..=16) + .map(|i| { + let k = i as f32 / 16.0; + pos2(x0 + (fx - x0) * k, body_r.max.y - body_r.height() * clip.fade_in.shape.gain(k)) + }) + .collect(); + painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); + painter.line_segment([pos2(x0, body_r.min.y), pos2(fx, body_r.min.y)], Stroke::new(1.0, fade_col)); + } + if clip.fade_out.len > 0 { + let fx = x_of(s, tl, clip.end() - clip.fade_out.len); + let pts: Vec = (0..=16) + .map(|i| { + let k = i as f32 / 16.0; + pos2(fx + (x1 - fx) * k, body_r.max.y - body_r.height() * clip.fade_out.shape.gain(1.0 - k)) + }) + .collect(); + painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); + } + if clip.edit_locked || clip.time_locked { + painter.text(pos2(r.max.x - 10.0, r.min.y + 7.0), Align2::CENTER_CENTER, "🔒", regular(9.0), Color32::WHITE); + } + let _ = app; +} + +fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { + let t = Tokens::DARK; + let (lo, hi, _) = p.range(); + let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0f32, 12.0f32) + } else if matches!(p, AutoParam::Plugin { .. }) { + plugin_range(track, p) + } else { + (lo, hi) + }; + let def = match p { + AutoParam::Volume => track.mixer.volume_db, + AutoParam::Pan(i) => track.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0), + _ => 0.0, + }; + let yv = |v: f32| -> f32 { + let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0); + lane.max.y - 4.0 - k * (lane.height() - 8.0) + }; + let lanes = track.lane(p); + let pts: Vec<(f32, f32)> = match lanes { + Some(l) if !l.points.is_empty() => { + let mut v: Vec<(f32, f32)> = Vec::new(); + v.push((lane.min.x, yv(l.value_at(sample_at(s, tl, lane.min.x), def)))); + for pt in &l.points { + let x = x_of(s, tl, pt.at); + if x > lane.min.x && x < lane.max.x { + v.push((x, yv(pt.value))); + } + } + v.push((lane.max.x, yv(l.value_at(sample_at(s, tl, lane.max.x), def)))); + v + } + _ => vec![(lane.min.x, yv(def)), (lane.max.x, yv(def))], + }; + let line: Vec = pts.iter().map(|(x, y)| pos2(*x, *y)).collect(); + painter.add(Shape::line(line, Stroke::new(1.5, t.automation_line))); + if let Some(l) = lanes { + for pt in &l.points { + let x = x_of(s, tl, pt.at); + if x > lane.min.x - 4.0 && x < lane.max.x + 4.0 { + painter.circle(pos2(x, yv(pt.value)), 3.0, Color32::from_rgb(40, 40, 40), Stroke::new(1.5, t.automation_line)); + } + } + } + painter.text(pos2(lane.min.x + 6.0, lane.min.y + 9.0), Align2::LEFT_CENTER, p.label(), regular(10.0), t.text_dim); +} + +fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) { + if let AutoParam::Plugin { slot, param } = p + && let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot)) + && let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) + && let Some(pi) = info.param(param) + { + return (pi.min, pi.max); + } + (0.0, 1.0) +} + +// ---- interaction ----------------------------------------------------------------------------- + +fn snap(s: &Session, at: Samples) -> Samples { + if s.edit.edit_mode.is_grid() { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } +} + +fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { + track.clips().iter().rev().find(|c| c.range().contains(at)) +} + +fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { + let id = ui.id().with(("lane", track.id.0)); + let resp = ui.interact(lane, id, Sense::click_and_drag()); + let s = app.engine.session().clone(); + let tool = s.edit.tool; + let mods = ui.input(|i| i.modifiers); + let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; + let at = sample_at(&s, tl, p.x).max(0); + let hit = clip_at(track, at).cloned(); + let upper = p.y < lane.center().y; + let near_edge = |c: &Clip| -> Option { + let xs = x_of(&s, tl, c.start); + let xe = x_of(&s, tl, c.end()); + if (p.x - xs).abs() < 6.0 { + Some(true) + } else if (p.x - xe).abs() < 6.0 { + Some(false) + } else { + None + } + }; + // Effective tool under the Smart tool. + let eff = match tool { + Tool::Smart => { + if let Some(c) = &hit { + if near_edge(c).is_some() { + Tool::Trim + } else if upper { + Tool::Selector + } else { + Tool::Grabber + } + } else { + Tool::Selector + } + } + other => other, + }; + if resp.hovered() { + let icon = match eff { + Tool::Trim => egui::CursorIcon::ResizeHorizontal, + Tool::Grabber => egui::CursorIcon::Grab, + Tool::Selector => egui::CursorIcon::Text, + Tool::Zoom => egui::CursorIcon::ZoomIn, + Tool::Pencil => egui::CursorIcon::Crosshair, + _ => egui::CursorIcon::Default, + }; + ui.ctx().set_cursor_icon(icon); + } + let auto_view = AutoParam::parse(&track.view); + if resp.drag_started() { + app.gesture = match eff { + Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), tracks: vec![track.id] }), + Tool::Grabber => hit.as_ref().map(|c| { + let clips = if s.edit.selected_clips.contains(&c.id) { s.edit.selected_clips.clone() } else { vec![c.id] }; + Gesture::MoveClips { clips, grab_at: at, delta: 0, from_track: track.id, to_track: track.id } + }), + Tool::Trim => hit.as_ref().map(|c| match near_edge(c) { + Some(false) => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, + _ if at - c.start < c.end() - at => Gesture::TrimStart { clip: c.id, track: track.id, to: c.start }, + _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, + }), + Tool::Scrubber => Some(Gesture::Scrub { last: at }), + Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), + Tool::Zoom => Some(Gesture::ZoomBox { start: p.x }), + }; + } + if resp.dragged() { + let to_track = app.edit_layout.rows.iter().find(|(_, r)| p.y >= r[1] && p.y <= r[3]).map(|(i, _)| TrackId(*i)); + match &mut app.gesture { + Some(Gesture::Select { tracks, .. }) => { + if let Some(t2) = to_track { + // Extend across the tracks between the anchor row and the pointer row. + let order: Vec = app.edit_layout.rows.iter().map(|(i, _)| TrackId(*i)).collect(); + let a = order.iter().position(|x| *x == track.id).unwrap_or(0); + let b = order.iter().position(|x| *x == t2).unwrap_or(a); + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + *tracks = order.get(lo..=hi).map(<[TrackId]>::to_vec).unwrap_or_default(); + } + } + Some(Gesture::MoveClips { grab_at, delta, to_track: tt, .. }) => { + let raw = at - *grab_at; + *delta = if s.edit.edit_mode.is_grid() { + snap(&s, hit.as_ref().map_or(*grab_at, |c| c.start) + raw) - hit.as_ref().map_or(*grab_at, |c| c.start) + } else { + raw + }; + if s.edit.edit_mode == soundcraft_model::EditMode::Spot { + *delta = 0; + } + if let Some(t2) = to_track { + *tt = t2; + } + } + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::Scrub { last }) => { + *last = at; + let _ = app.engine.execute("transport.locate", &json!({"at": at})); + } + Some(Gesture::Pencil { points, .. }) => { + if let Some(param) = &auto_view { + let (lo, hi, _) = param.range(); + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(track, param) + } else { + (lo, hi) + }; + let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); + let v = lo + (hi - lo) * k; + if points.last().is_none_or(|(a, _)| (at - a).abs() as f64 > s.edit.zoom.samples_per_px * 3.0) { + points.push((at, v)); + } + } + } + _ => {} + } + // Live selection feedback. + if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { + let r = Range::new(*anchor, snap(&s, at)); + let ids: Vec = tracks.iter().map(|x| x.0).collect(); + let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); + } + } + if resp.drag_stopped() { + let g = app.gesture.take(); + match g { + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { + let ids: Vec = clips.iter().map(|c| c.0).collect(); + let mut params = json!({"clips": ids, "by": delta}); + if to_track != from_track { + params["track"] = json!(to_track.0); + } + if delta != 0 || to_track != from_track { + let _ = app.run("edit.move_clips", params); + } + } + Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), + Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::Pencil { track, points }) => { + if let Some(param) = &auto_view { + let pid = automation_id(param); + for (a, v) in points { + let _ = app.run("automation.set_point", json!({"track": track.0, "param": pid, "at": a, "value": v})); + } + } + } + Some(Gesture::ZoomBox { start }) => { + let a = sample_at(&s, tl, start.min(p.x)); + let b = sample_at(&s, tl, start.max(p.x)); + if b > a { + let spp = ((b - a) as f64 / f64::from(tl.width())).max(0.25); + let _ = app.run("view.zoom_set", json!({"samples_per_px": spp})); + let _ = app.run("view.scroll", json!({"to": a})); + } + } + _ => {} + } + } + if resp.clicked() { + match eff { + Tool::Grabber => { + if let Some(c) = &hit { + let mut ids = if mods.shift { s.edit.selected_clips.iter().map(|x| x.0).collect::>() } else { Vec::new() }; + ids.push(c.id.0); + let _ = app.run("edit.select", json!({"clips": ids})); + } + } + Tool::Zoom => { + let _ = app.run(if mods.alt { "view.zoom_out" } else { "view.zoom_in" }, json!({})); + } + Tool::Pencil => { + if let Some(param) = &auto_view { + let (lo, hi, _) = param.range(); + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0, 12.0) } else { (lo, hi) }; + let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); + let _ = app.run( + "automation.set_point", + json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}), + ); + } + } + _ => { + let at = snap(&s, at); + if mods.shift && s.edit.selected_tracks.contains(&track.id) { + let sel = s.edit.selection; + let r = if at < sel.start { Range::new(at, sel.end) } else { Range::new(sel.start, at) }; + let _ = app.run("edit.select", json!({"start": r.start, "end": r.end})); + } else { + let _ = app.run("edit.select", json!({"tracks": [track.id.0], "start": at})); + } + } + } + } + if resp.double_clicked() + && eff != Tool::Pencil + && let Some(c) = &hit + { + let _ = app.run("edit.select", json!({"clips": [c.id.0]})); + } + resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id))); +} + +fn automation_id(p: &AutoParam) -> String { + match p { + AutoParam::Volume => "volume".into(), + AutoParam::Pan(0) => "pan".into(), + AutoParam::Pan(_) => "pan2".into(), + AutoParam::Mute => "mute".into(), + AutoParam::SendLevel(i) => format!("send_{}_level", (b'a' + i) as char), + AutoParam::SendPan(i) => format!("send_{}_pan", (b'a' + i) as char), + AutoParam::SendMute(i) => format!("send_{}_mute", (b'a' + i) as char), + AutoParam::Plugin { slot, param } => format!("plugin:{}:{param}", (b'a' + slot) as char), + } +} + +fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, start: bool) { + let cmd = if start { "edit.trim_start_to_insertion" } else { "edit.trim_end_to_insertion" }; + // Trims through the command layer: select the clip's track and position the insertion. + let sel_before = app.engine.session().edit.selection; + let tracks_before: Vec = app.engine.session().edit.selected_tracks.iter().map(|x| x.0).collect(); + let c = app.engine.session().find_clip(clip).map(|(_, c)| c.clone()); + if let Some(c) = c { + if start && to < c.start || !start && to > c.end() { + // Extending: use trim to fill selection with handles. + let r = if start { Range::new(to, c.end()) } else { Range::new(c.start, to) }; + let _ = app.run("edit.trim_to_fill_selection", json!({"clips": [clip.0], "start": r.start, "end": r.end})); + } else { + let _ = app.run(cmd, json!({"tracks": [track.0], "at": to})); + } + } + let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end})); +} + +fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { + let Some(c) = clip else { + if ui.button("Paste").clicked() { + let _ = app.run("edit.paste", json!({})); + } + return; + }; + let ids = json!([c.0]); + for (label, cmd) in [ + ("Mute/Unmute", "edit.mute_clips"), + ("Edit Lock/Unlock", "clip.edit_lock"), + ("Time Lock/Unlock", "clip.time_lock"), + ("Quantize to Grid", "clip.quantize_to_grid"), + ("Render Clip Gain", "clip.gain_render"), + ("Bring to Front", "clip.bring_to_front"), + ("Send to Back", "clip.send_to_back"), + ] { + if ui.button(label).clicked() { + let _ = app.run(cmd, json!({"clips": ids})); + } + } + ui.separator(); + if ui.button("Rename...").clicked() { + let name = app.engine.session().find_clip(c).map(|(_, x)| x.name.clone()).unwrap_or_default(); + app.dialogs.open_rename_clip(c, &name); + } + if ui.button("Delete").clicked() { + let _ = app.run("edit.select", json!({"clips": ids})); + let _ = app.run("edit.clear", json!({})); + } +} + +/// Selection overlay, insertion point and playhead across rulers and tracks. +fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { + let t = Tokens::DARK; + let mut scroll_to: Option = None; + let s = app.engine.session(); + let painter = ui.painter().with_clip_rect(area); + let sel = s.edit.selection; + // Timeline selection markers on the main ruler. + if !sel.is_empty() { + for x in [x_of(s, tl, sel.start), x_of(s, tl, sel.end)] { + painter.add(Shape::convex_polygon( + vec![pos2(x - 5.0, area.min.y + 2.0), pos2(x + 5.0, area.min.y + 2.0), pos2(x, area.min.y + 9.0)], + t.accent, + Stroke::NONE, + )); + } + } else if !app.is_playing() { + let x = x_of(s, tl, sel.start); + let blink = (ui.input(|i| i.time) * 2.0) as i64 % 2 == 0; + if blink { + for (_, r) in &app.edit_layout.rows { + if s.edit.selected_tracks.iter().any(|_| true) { + painter.line_segment([pos2(x, r[1].max(tl.min.y)), pos2(x, r[3])], Stroke::new(1.0, t.insertion)); + } + } + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + if app.is_playing() { + let x = x_of(s, tl, app.position()); + painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead)); + // Page scrolling. + if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) { + scroll_to = Some(app.position()); + } + } + // Ghost of a clip move. + if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { + let row = app.edit_layout.rows.iter().find(|(i, _)| *i == to_track.0).map(|(_, r)| *r); + for c in clips { + if let (Some((_, clip)), Some(r)) = (s.find_clip(*c), row) { + let x0 = x_of(s, tl, clip.start + delta); + let x1 = x_of(s, tl, clip.end() + delta); + painter.rect_stroke( + Rect::from_min_max(pos2(x0, r[1] + 1.0), pos2(x1, r[3] - 1.0)), + 2.0, + Stroke::new(2.0, Color32::WHITE), + StrokeKind::Inside, + ); + } + } + } + if let Some(Gesture::TrimStart { to, clip, .. } | Gesture::TrimEnd { to, clip, .. }) = &app.gesture + && let Some((tid, _)) = s.find_clip(*clip) + && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) + { + let x = x_of(s, tl, *to); + painter.line_segment([pos2(x, r[1]), pos2(x, r[3])], Stroke::new(2.0, Color32::WHITE)); + } + if let Some(at) = scroll_to { + let _ = app.engine.execute("view.scroll", &json!({"to": at})); + } +} diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs new file mode 100644 index 0000000..6905934 --- /dev/null +++ b/crates/ui-egui/src/fonts.rs @@ -0,0 +1,71 @@ +//! Fonts: egui's bundled defaults, plus the operating system's own UI fonts loaded at runtime when +//! present (never redistributed). A "bold" family is always defined so the UI can use it. + +use egui::{FontData, FontDefinitions, FontFamily}; +use std::sync::Arc; + +/// (path, ttc index) candidates for regular and bold UI text, by platform. +const REGULAR: &[(&str, u32)] = &[ + ("/System/Library/Fonts/HelveticaNeue.ttc", 0), + ("/System/Library/Fonts/Helvetica.ttc", 0), + ("C:\\Windows\\Fonts\\segoeui.ttf", 0), + ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 0), + ("/usr/share/fonts/TTF/DejaVuSans.ttf", 0), + ("/usr/share/fonts/dejavu/DejaVuSans.ttf", 0), + ("/usr/local/share/fonts/dejavu/DejaVuSans.ttf", 0), + ("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 0), +]; +const BOLD: &[(&str, u32)] = &[ + ("/System/Library/Fonts/HelveticaNeue.ttc", 1), + ("/System/Library/Fonts/Helvetica.ttc", 1), + ("C:\\Windows\\Fonts\\segoeuib.ttf", 0), + ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0), + ("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 0), + ("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0), + ("/usr/local/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0), + ("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0), +]; + +#[cfg(not(target_arch = "wasm32"))] +fn load(cands: &[(&str, u32)]) -> Option { + if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() { + return None; + } + for (p, idx) in cands { + if let Ok(bytes) = std::fs::read(p) { + let mut fd = FontData::from_owned(bytes); + fd.index = *idx; + return Some(fd); + } + } + None +} + +#[cfg(target_arch = "wasm32")] +fn load(_: &[(&str, u32)]) -> Option { + None +} + +pub fn definitions() -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + let base: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let mut bold_family = base.clone(); + if let Some(reg) = load(REGULAR) { + fonts.font_data.insert("system-regular".into(), Arc::new(reg)); + if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { + f.insert(0, "system-regular".into()); + } + } + if let Some(b) = load(BOLD) { + fonts.font_data.insert("system-bold".into(), Arc::new(b)); + bold_family.insert(0, "system-bold".into()); + } else if fonts.font_data.contains_key("system-regular") { + bold_family.insert(0, "system-regular".into()); + } + fonts.families.insert(FontFamily::Name("bold".into()), bold_family); + fonts +} + +pub fn install(ctx: &egui::Context) { + ctx.set_fonts(definitions()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 0000000..7de4119 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,160 @@ +//! Icons drawn procedurally with the egui painter. Original SoundCraft artwork (MIT OR Apache-2.0); +//! no image files and nothing derived from any other product's icons. + +use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; + +fn p(r: Rect, x: f32, y: f32) -> Pos2 { + pos2(r.min.x + r.width() * x, r.min.y + r.height() * y) +} + +/// Draw icon `name` inside `r` with colour `c`. +pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { + let s = Stroke::new((r.width() / 14.0).clamp(1.2, 2.4), c); + match name { + "play" => { + painter.add(Shape::convex_polygon(vec![p(r, 0.28, 0.18), p(r, 0.82, 0.5), p(r, 0.28, 0.82)], c, Stroke::NONE)); + } + "stop" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.26, 0.26), p(r, 0.74, 0.74)), 1.0, c); + } + "record" => { + painter.circle_filled(r.center(), r.width() * 0.27, c); + } + "rtz" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.24, 0.26), p(r, 0.34, 0.74)), 0.0, c); + painter.add(Shape::convex_polygon(vec![p(r, 0.76, 0.26), p(r, 0.76, 0.74), p(r, 0.36, 0.5)], c, Stroke::NONE)); + } + "end" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.66, 0.26), p(r, 0.76, 0.74)), 0.0, c); + painter.add(Shape::convex_polygon(vec![p(r, 0.24, 0.26), p(r, 0.64, 0.5), p(r, 0.24, 0.74)], c, Stroke::NONE)); + } + "rewind" => { + for dx in [0.0, 0.26] { + painter.add(Shape::convex_polygon(vec![p(r, 0.5 + dx, 0.28), p(r, 0.5 + dx, 0.72), p(r, 0.22 + dx, 0.5)], c, Stroke::NONE)); + } + } + "ffwd" => { + for dx in [0.0, 0.26] { + painter.add(Shape::convex_polygon(vec![p(r, 0.24 + dx, 0.28), p(r, 0.52 + dx, 0.5), p(r, 0.24 + dx, 0.72)], c, Stroke::NONE)); + } + } + "loop" => { + let rr = Rect::from_min_max(p(r, 0.2, 0.3), p(r, 0.8, 0.7)); + painter.rect_stroke(rr, rr.height() * 0.5, s, egui::StrokeKind::Middle); + painter.add(Shape::convex_polygon(vec![p(r, 0.55, 0.2), p(r, 0.68, 0.3), p(r, 0.55, 0.4)], c, Stroke::NONE)); + } + "zoom" => { + painter.circle_stroke(p(r, 0.42, 0.4), r.width() * 0.2, s); + painter.line_segment([p(r, 0.56, 0.55), p(r, 0.8, 0.8)], Stroke::new(s.width * 1.6, c)); + } + "trim" => { + painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s); + painter.line_segment([p(r, 0.15, 0.5), p(r, 0.85, 0.5)], s); + for (a, b, d) in [(0.15, 0.5, 1.0), (0.85, 0.5, -1.0)] { + painter.add(Shape::convex_polygon(vec![p(r, a, b), p(r, a + 0.12 * d, b - 0.1), p(r, a + 0.12 * d, b + 0.1)], c, Stroke::NONE)); + } + } + "selector" => { + painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s); + painter.line_segment([p(r, 0.4, 0.2), p(r, 0.6, 0.2)], s); + painter.line_segment([p(r, 0.4, 0.8), p(r, 0.6, 0.8)], s); + // Waveform hint on either side. + for (i, h) in [0.1, 0.22, 0.14, 0.26, 0.12].iter().enumerate() { + let x = 0.12 + i as f32 * 0.06; + painter.line_segment([p(r, x, 0.5 - h), p(r, x, 0.5 + h)], Stroke::new(s.width * 0.7, c)); + let x2 = 0.88 - i as f32 * 0.06; + painter.line_segment([p(r, x2, 0.5 - h), p(r, x2, 0.5 + h)], Stroke::new(s.width * 0.7, c)); + } + } + "grabber" => { + // A stylised open hand: palm + four fingers + thumb. + let palm = Rect::from_min_max(p(r, 0.3, 0.45), p(r, 0.72, 0.82)); + painter.rect_stroke(palm, 4.0, s, egui::StrokeKind::Middle); + for (i, top) in [0.22, 0.16, 0.18, 0.26].iter().enumerate() { + let x = 0.34 + i as f32 * 0.11; + painter.line_segment([p(r, x, 0.47), p(r, x, *top)], s); + } + painter.line_segment([p(r, 0.3, 0.62), p(r, 0.18, 0.48)], s); + } + "scrubber" => { + painter.add(Shape::convex_polygon( + vec![p(r, 0.18, 0.4), p(r, 0.3, 0.4), p(r, 0.46, 0.24), p(r, 0.46, 0.76), p(r, 0.3, 0.6), p(r, 0.18, 0.6)], + c, + Stroke::NONE, + )); + for (k, rad) in [0.14f32, 0.26].iter().enumerate() { + let n = 8; + let pts: Vec = (0..=n) + .map(|i| { + let a = -0.8 + 1.6 * i as f32 / n as f32; + let cc = p(r, 0.46, 0.5); + pos2(cc.x + a.cos() * r.width() * rad, cc.y + a.sin() * r.height() * rad) + }) + .collect(); + painter.add(Shape::line(pts, Stroke::new(s.width * (1.0 - k as f32 * 0.2), c))); + } + } + "pencil" => { + painter.line_segment([p(r, 0.25, 0.75), p(r, 0.72, 0.28)], Stroke::new(s.width * 2.2, c)); + painter.add(Shape::convex_polygon(vec![p(r, 0.18, 0.82), p(r, 0.22, 0.66), p(r, 0.34, 0.78)], c, Stroke::NONE)); + } + "speaker" => { + painter.add(Shape::convex_polygon( + vec![p(r, 0.2, 0.4), p(r, 0.34, 0.4), p(r, 0.52, 0.22), p(r, 0.52, 0.78), p(r, 0.34, 0.6), p(r, 0.2, 0.6)], + c, + Stroke::NONE, + )); + } + "click" => { + painter.add(Shape::convex_polygon(vec![p(r, 0.5, 0.15), p(r, 0.78, 0.85), p(r, 0.22, 0.85)], Color32::TRANSPARENT, s)); + painter.line_segment([p(r, 0.5, 0.7), p(r, 0.7, 0.25)], s); + } + "link" => { + painter.circle_stroke(p(r, 0.38, 0.5), r.width() * 0.16, s); + painter.circle_stroke(p(r, 0.62, 0.5), r.width() * 0.16, s); + } + "plus" => { + painter.line_segment([p(r, 0.5, 0.25), p(r, 0.5, 0.75)], s); + painter.line_segment([p(r, 0.25, 0.5), p(r, 0.75, 0.5)], s); + } + "minus" => { + painter.line_segment([p(r, 0.25, 0.5), p(r, 0.75, 0.5)], s); + } + "triangle_down" => { + painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE)); + } + "triangle_right" => { + painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE)); + } + "note" => { + painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c); + painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s); + painter.line_segment([p(r, 0.5, 0.2), p(r, 0.72, 0.32)], s); + } + "gear" => { + painter.circle_stroke(r.center(), r.width() * 0.2, s); + for i in 0..8 { + let a = i as f32 * std::f32::consts::TAU / 8.0; + let c0 = r.center() + vec2(a.cos(), a.sin()) * r.width() * 0.22; + let c1 = r.center() + vec2(a.cos(), a.sin()) * r.width() * 0.34; + painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c)); + } + } + "wave" => { + let n = 24; + let pts: Vec = (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + pos2( + r.min.x + r.width() * (0.12 + 0.76 * t), + r.center().y - (t * 18.0).sin() * r.height() * 0.28 * (1.0 - (t - 0.5).abs() * 1.6).max(0.1), + ) + }) + .collect(); + painter.add(Shape::line(pts, s)); + } + _ => { + painter.rect_stroke(r.shrink(r.width() * 0.25), 1.0, s, egui::StrokeKind::Middle); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 0000000..440bd1f --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,433 @@ +//! SoundCraft's egui user interface. +//! +//! The UI is a thin shell over [`soundcraft_engine::Engine`]: it draws engine state and acts by +//! executing commands. It can be replaced without touching anything below it. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod control; +pub mod dialogs; +pub mod edit_window; +pub mod fonts; +pub mod icons; +pub mod menus; +pub mod mix_window; +pub mod panels; +pub mod shortcuts; +pub mod theme; +pub mod toolbar; +pub mod widgets; + +use serde_json::{Value, json}; +use soundcraft_engine::{Engine, TransportRequest}; +use soundcraft_model::TrackId; +use soundcraft_playback::Player; +use soundcraft_time::{Range, Samples}; +use std::collections::HashMap; +use std::sync::mpsc::Receiver; + +pub use control::ControlRequest; + +/// Which main window is in front (Window › Mix / Edit). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum MainWindow { + Edit, + Mix, +} + +/// UI state (serde, so agents can read and set it). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UiState { + pub window: MainWindow, + pub show_tracks_list: bool, + pub show_clip_list: bool, + pub show_transport: bool, + pub show_memory_locations: bool, + pub show_big_counter: bool, + pub show_undo_history: bool, + pub show_session_info: bool, + pub show_about: bool, + pub show_midi_editor: bool, + pub show_universe: bool, + pub narrow_mix: bool, + /// Mix window sections: inserts_ae, inserts_fj, sends_ae, sends_fj, io, comments, eq_curve, color. + pub mix_views: Vec, + /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color. + pub edit_views: Vec, + /// Open plugin windows (track, slot). + pub plugin_windows: Vec<(TrackId, usize)>, + pub audiosuite: Option, + pub status: String, +} + +impl Default for UiState { + fn default() -> Self { + UiState { + window: MainWindow::Edit, + show_tracks_list: true, + show_clip_list: true, + show_transport: false, + show_memory_locations: false, + show_big_counter: false, + show_undo_history: false, + show_session_info: false, + show_about: false, + show_midi_editor: false, + show_universe: false, + narrow_mix: false, + mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], + edit_views: vec!["color".into()], + plugin_windows: Vec::new(), + audiosuite: None, + status: String::new(), + } + } +} + +/// Host services (file dialogs) injected by the app; absent in tests and on the web. +#[derive(Default)] +pub struct Services { + pub pick_open: Option Option>>, + pub pick_save: Option Option>>, +} + +/// Ballistic meter display state per strip. +#[derive(Debug, Clone, Copy, Default)] +pub struct MeterDisplay { + pub level: [f32; 2], + pub hold: [f32; 2], + pub hold_age: f32, + pub clip: bool, + pub gr: f32, +} + +/// An in-progress mouse gesture in the Edit window. +#[derive(Debug, Clone)] +pub enum Gesture { + Select { track: TrackId, anchor: Samples, tracks: Vec }, + MoveClips { clips: Vec, grab_at: Samples, delta: Samples, from_track: TrackId, to_track: TrackId }, + TrimStart { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, + TrimEnd { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, + Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, + Scrub { last: Samples }, + Fader { track: TrackId, start_db: f32 }, + Pencil { track: TrackId, points: Vec<(Samples, f32)> }, + ZoomBox { start: f32 }, +} + +pub struct SoundApp { + pub engine: Engine, + pub player: Option, + pub ui: UiState, + pub services: Services, + pub dialogs: dialogs::Dialogs, + pub meters: HashMap, + pub main_meter: MeterDisplay, + pub gesture: Option, + pub edit_layout: edit_window::EditLayout, + pub synthetic: Vec, + control_rx: Option>, + pending_shots: Vec, + last_rev: u64, + fonts_ready: bool, + fonts_installed: bool, + /// Transport simulation when there is no player (tests, offscreen renders). + sim: Option<(Samples, Option, Option)>, + pub quit_requested: bool, + pub frame_ms: f32, + last_frame: Option, +} + +impl SoundApp { + pub fn new(engine: Engine, player: Option, services: Services) -> Self { + SoundApp { + engine, + player, + ui: UiState::default(), + services, + dialogs: dialogs::Dialogs::default(), + meters: HashMap::new(), + main_meter: MeterDisplay::default(), + gesture: None, + edit_layout: edit_window::EditLayout::default(), + synthetic: Vec::new(), + control_rx: None, + pending_shots: Vec::new(), + last_rev: 0, + fonts_ready: false, + fonts_installed: false, + sim: None, + quit_requested: false, + frame_ms: 0.0, + last_frame: None, + } + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Run a command; errors go to the status line. Returns the result. + pub fn run(&mut self, id: &str, params: Value) -> Result { + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + return r; + } + match self.engine.execute(id, ¶ms) { + Ok(v) => { + if id == "app.quit" { + self.quit_requested = true; + } + Ok(v) + } + Err(e) => { + self.ui.status = e.to_string(); + log::warn!("{id}: {e}"); + Err(e.to_string()) + } + } + } + + pub fn is_playing(&self) -> bool { + self.engine.transport.playing + } + + /// Current transport position (playhead while playing, else the edit insertion). + pub fn position(&self) -> Samples { + if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start } + } + + fn start_play(&mut self, from: Samples, end: Option, looped: Option) { + if let Some(p) = &self.player { + p.update_session(self.engine.session_arc()); + p.play(from, end, looped); + } else { + self.sim = Some((from, end, looped)); + } + self.engine.transport.playing = true; + self.engine.transport.position = from; + } + + fn stop_play(&mut self) { + if let Some(p) = &self.player { + p.stop(); + } + self.sim = None; + self.engine.transport.playing = false; + self.engine.transport.recording = false; + let s = self.engine.session(); + if s.edit.insertion_follows_playback { + let pos = self.engine.transport.position; + self.engine.session_mut().edit.selection = Range::point(pos); + } + } + + /// Play from the selection, honouring loop playback and selection end. + pub fn play_from_selection(&mut self) { + let s = self.engine.session(); + let sel = s.edit.selection; + let preroll = if s.edit.pre_post_roll { s.edit.pre_roll } else { 0 }; + let postroll = if s.edit.pre_post_roll { s.edit.post_roll } else { 0 }; + if s.edit.loop_playback && !sel.is_empty() { + self.start_play(sel.start, None, Some(sel)); + } else if !sel.is_empty() { + self.start_play((sel.start - preroll).max(0), Some(sel.end + postroll), None); + } else { + self.start_play((sel.start - preroll).max(0), None, None); + } + } + + fn handle_transport(&mut self, dt: f32) { + let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); + for r in reqs { + match r { + TransportRequest::Play => { + if !self.is_playing() { + self.play_from_selection(); + } + } + TransportRequest::Stop | TransportRequest::Pause => self.stop_play(), + TransportRequest::TogglePlay => { + if self.is_playing() { + self.stop_play(); + } else { + self.play_from_selection(); + } + } + TransportRequest::Record => { + self.engine.transport.recording = true; + if !self.is_playing() { + self.play_from_selection(); + } + } + TransportRequest::Locate(at) => { + if self.is_playing() { + if let Some(p) = &self.player { + p.locate(at); + } + if let Some(sim) = &mut self.sim { + sim.0 = at; + } + } + self.engine.transport.position = at; + } + TransportRequest::PlaySelection => { + let sel = self.engine.session().edit.selection; + self.start_play(sel.start, Some(sel.end.max(sel.start + 1)), None); + } + TransportRequest::HalfSpeed => { + if let Some(p) = &self.player { + p.set_speed(0.5); + } + self.play_from_selection(); + } + TransportRequest::Scrub(at) => self.engine.transport.position = at, + TransportRequest::AllNotesOff => {} + } + } + // Read back the position. + if let Some(p) = &self.player { + let playing = p.is_playing(); + if self.engine.transport.playing && !playing { + self.engine.transport.playing = false; + p.set_speed(1.0); + } + if self.engine.transport.playing { + self.engine.transport.position = p.position(); + } + let snap = p.meters(); + for (id, m) in &snap.tracks { + let d = self.meters.entry(*id).or_default(); + feed_meter(d, m.peak, m.gain_reduction_db, dt); + } + let main = snap.main.peak; + feed_meter(&mut self.main_meter, main, 0.0, dt); + } else if let Some((pos, end, looped)) = &mut self.sim { + let sr = self.engine.session().sample_rate.as_f64(); + *pos += (f64::from(dt) * sr) as i64; + if let Some(l) = looped + && *pos >= l.end + { + *pos = l.start; + } + let stop = end.is_some_and(|e| *pos >= e); + self.engine.transport.position = *pos; + if stop { + self.stop_play(); + } + } + if !self.engine.transport.playing { + for d in self.meters.values_mut() { + feed_meter(d, [0.0, 0.0], 0.0, dt); + } + feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt); + } + } + + /// Per-frame logic: control channel, transport, document sync. + pub fn logic(&mut self, ctx: &egui::Context) { + // Fonts set now take effect next frame, so draw only from the frame after. + if !self.fonts_ready { + if self.fonts_installed { + self.fonts_ready = true; + } else { + fonts::install(ctx); + theme::apply(ctx); + self.fonts_installed = true; + ctx.request_repaint(); + } + } + let now = ctx.input(|i| i.time); + let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); + self.last_frame = Some(now); + self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; + control::drain(self, ctx); + if self.engine.revision != self.last_rev { + self.last_rev = self.engine.revision; + if let Some(p) = &self.player { + p.update_session(self.engine.session_arc()); + } + } + self.handle_transport(dt); + if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() { + ctx.request_repaint(); + } + control::collect_screenshots(self, ctx); + } + + /// Feed queued synthetic input (from the control channel) into egui. + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if self.synthetic.is_empty() { + return; + } + let mut n = 0; + for e in &self.synthetic { + n += 1; + if matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. }) { + break; + } + if matches!(e, egui::Event::PointerButton { pressed: true, .. }) { + break; + } + } + raw.events.extend(self.synthetic.drain(..n.min(self.synthetic.len()))); + } + + /// Lay out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + shortcuts::handle(self, &ctx); + menus::menu_bar(self, ui); + match self.ui.window { + MainWindow::Edit => edit_window::show(self, ui), + MainWindow::Mix => mix_window::show(self, ui), + } + panels::floating(self, &ctx); + dialogs::show(self, &ctx); + } + + /// UI state as JSON (control channel `ui.inspect`). + pub fn inspect(&self, ctx: &egui::Context) -> Value { + let size = ctx.content_rect().size(); + json!({ + "ui": self.ui, + "window_size": [size.x, size.y], + "playing": self.is_playing(), + "position": self.position(), + "dialog": self.dialogs.open_name(), + "frame_ms": self.frame_ms, + "audio_device": self.player.as_ref().map(|p| p.device_name.clone()), + "status": self.ui.status, + }) + } +} + +fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { + // Instant attack, ~26 dB/s release, 2 s peak hold. + let fall = 10f32.powf(-26.0 * dt / 20.0); + for c in 0..2 { + let v = peak.get(c).copied().unwrap_or(0.0); + let lvl = d.level.get(c).copied().unwrap_or(0.0); + let nv = if v >= lvl { v } else { (lvl * fall).max(v) }; + if let Some(x) = d.level.get_mut(c) { + *x = if nv < 1e-5 { 0.0 } else { nv }; + } + if v >= d.hold.get(c).copied().unwrap_or(0.0) { + if let Some(h) = d.hold.get_mut(c) { + *h = v; + } + d.hold_age = 0.0; + } + if v >= 1.0 { + d.clip = true; + } + } + d.hold_age += dt; + if d.hold_age > 2.0 { + d.hold = d.level; + } + d.gr = if gr > d.gr { gr } else { d.gr * fall }; +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000..35a2511 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,392 @@ +//! The menu bar, built from the incumbent's menu catalog so every item has its place. Items +//! that map to a command are live; the rest are shown disabled (and listed in the parity report). + +use crate::{MainWindow, SoundApp}; +use serde_json::{Value, json}; +use soundcraft_engine::catalog; + +/// UI-layer commands: (id, label, catalog path or "", shortcut). +pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ + ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), + ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), + ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")), + ("window.transport", "Transport", "Window > Transport", Some("Cmd+1")), + ("window.big_counter", "Big Counter", "Window > Big Counter", Some("Cmd+3")), + ("window.memory_locations", "Memory Locations", "Window > Memory Locations", Some("Cmd+5")), + ("window.undo_history", "Undo History", "Window > Undo History", None), + ("window.clip_list", "Clip List", "Window > Clip List", None), + ("window.track_list", "Track List", "View > Other Displays > Track List", None), + ("window.clip_list_view", "Clip List", "View > Other Displays > Clip List", None), + ("window.narrow_mix", "Narrow Mix", "View > Narrow Mix", Some("Cmd+Alt+M")), + ("window.session_info", "Session Info", "", None), + ("window.about", "About SoundCraft", "", None), + ("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")), + ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")), + ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None), + ("window.universe", "Universe", "View > Other Displays > Universe", None), + ("view.mix_section", "Mix Window View", "", None), + ("ui.new_tracks_dialog", "New Tracks…", "", None), + ("ui.bounce_dialog", "Bounce Mix…", "", None), + ("ui.set", "Set UI State", "", None), +]; + +/// Extra catalog mappings handled by the UI layer. +pub fn ui_aliases() -> Vec<(&'static str, &'static str)> { + let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect(); + for sec in + ["Inserts A-E", "Inserts F-J", "Sends A-E", "Sends F-J", "I/O", "Track Color", "Comments", "EQ Curve", "Meters and Faders", "All", "Minimal"] + { + v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section")); + } + v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process"))); + v +} + +/// AudioSuite catalog entries → our processes (functional equivalents, our own plugins). +pub const AUDIOSUITE: &[(&str, &str)] = &[ + ("AudioSuite > EQ > Channel Strip", "channel_strip"), + ("AudioSuite > EQ > EQ3 1-Band", "eq_1band"), + ("AudioSuite > EQ > EQ3 7-Band", "eq_7band"), + ("AudioSuite > Dynamics > BF-76", "compressor"), + ("AudioSuite > Dynamics > Channel Strip", "channel_strip"), + ("AudioSuite > Dynamics > Dyn3 Compressor/Limiter", "compressor"), + ("AudioSuite > Dynamics > Dyn3 De-Esser", "de_esser"), + ("AudioSuite > Dynamics > Dyn3 Expander/Gate", "expander_gate"), + ("AudioSuite > Dynamics > Maxim", "maximizer"), + ("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"), + ("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"), + ("AudioSuite > Pitch Shift > Time Shift", "time_stretch"), + ("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"), + ("AudioSuite > Reverb > D-Verb", "room_reverb"), + ("AudioSuite > Delay > Mod Delay III", "mod_delay"), + ("AudioSuite > Modulation > Sci-Fi", "flanger"), + ("AudioSuite > Harmonic > Eleven Lite", "saturator"), + ("AudioSuite > Harmonic > Lo-Fi", "lofi"), + ("AudioSuite > Harmonic > Recti-Fi", "rectifier"), + ("AudioSuite > Harmonic > SansAmp PSA-1", "saturator"), + ("AudioSuite > Other > DC Offset Removal", "dc_offset_removal"), + ("AudioSuite > Other > Duplicate", "duplicate"), + ("AudioSuite > Other > Gain", "gain"), + ("AudioSuite > Other > Invert", "invert"), + ("AudioSuite > Other > Normalize", "normalize"), + ("AudioSuite > Other > Reverse", "reverse"), + ("AudioSuite > Other > Signal Generator", "signal_generator"), + ("AudioSuite > Other > Time Compression Expansion", "time_stretch"), +]; + +/// Commands that open a dialog when invoked from a menu (programmatic calls never do). +const DIALOG_COMMANDS: &[&str] = &[ + "track.new", + "file.bounce_mix", + "session.new", + "file.import_audio", + "file.import_midi", + "session.open", + "session.save_as", + "session.save_copy", + "track.group", + "edit.repeat", + "edit.shift", + "edit.fades_create", + "setup.session", + "event.quantize", + "event.transpose", + "edit.strip_silence", +]; + +struct MenuNode { + label: String, + path: String, + children: Vec, +} + +fn tree() -> Vec { + let mut roots: Vec = Vec::new(); + for line in catalog::catalog() { + let parts: Vec<&str> = line.split(" > ").collect(); + let mut level = &mut roots; + let mut path = String::new(); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + path.push_str(" > "); + } + path.push_str(p); + let idx = match level.iter().position(|n| n.label == *p) { + Some(i) => i, + None => { + level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() }); + level.len() - 1 + } + }; + let Some(node) = level.get_mut(idx) else { break }; + level = &mut node.children; + } + } + roots +} + +pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::DARK; + egui::Panel::top("menu_bar") + .exact_size(24.0) + .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(22, 22, 23)).inner_margin(egui::Margin::symmetric(8, 2))) + .show(ui, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + ui.menu_button("SoundCraft", |ui| { + if ui.button("About SoundCraft").clicked() { + app.ui.show_about = true; + } + if ui.button("Session Info").clicked() { + app.ui.show_session_info = true; + } + ui.separator(); + if ui.button("Quit").clicked() { + let _ = app.run("app.quit", json!({})); + } + }); + let extra = ui_aliases(); + for root in tree() { + ui.menu_button(&root.label, |ui| { + for c in &root.children { + menu_node(app, ui, c, &extra); + } + if root.label == "AudioSuite" { + ui.separator(); + ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim)); + } + }); + } + let title = format!( + "{}{} — {}", + app.engine.session().name, + if app.engine.is_dirty() { " *" } else { "" }, + if app.ui.window == MainWindow::Edit { "Edit" } else { "Mix" } + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(title).color(t.text_dim)); + }); + }); + }); +} + +fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) { + if !n.children.is_empty() { + ui.menu_button(&n.label, |ui| { + for c in &n.children { + menu_node(app, ui, c, extra); + } + }); + return; + } + let id = catalog::implemented_by(&n.path, extra); + let enabled = match id.as_deref() { + Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true, + Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), + None => false, + }; + let checked = checked_state(app, &n.path, id.as_deref()); + let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() }; + let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); + let btn = egui::Button::new(label).shortcut_text(shortcut); + if ui.add_enabled(enabled, btn).clicked() + && let Some(id) = id + { + invoke_menu(app, &id, &n.path); + ui.close(); + } +} + +fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { + let e = &app.engine.session().edit; + match id { + Some("options.loop_playback") => e.loop_playback, + Some("options.loop_record") => e.loop_record, + Some("options.quickpunch") => e.quickpunch, + Some("options.pre_post_roll") => e.pre_post_roll, + Some("options.link_timeline_edit") => e.link_timeline_edit, + Some("options.link_track_edit") => e.link_track_edit, + Some("options.insertion_follows_playback") => e.insertion_follows_playback, + Some("options.tab_to_transient") => e.tab_to_transient, + Some("options.automation_follows_edit") => e.automation_follows_edit, + Some("options.markers_follow_edit") => e.markers_follow_edit, + Some("options.click") => e.click, + Some("options.delay_compensation") => e.delay_compensation, + Some("options.pre_fader_metering") => e.pre_fader_metering, + Some("view.ruler") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.rulers.iter().any(|x| x == r)), + Some("view.main_counter") => path.rsplit(" > ").next().map(ruler_id).is_some_and(|r| e.main_counter.id() == r), + Some("window.mix") => app.ui.window == MainWindow::Mix, + Some("window.edit") => app.ui.window == MainWindow::Edit, + Some("window.track_list") => app.ui.show_tracks_list, + Some("window.clip_list_view") | Some("window.clip_list") => app.ui.show_clip_list, + Some("window.narrow_mix") => app.ui.narrow_mix, + Some("view.mix_section") => path.rsplit(" > ").next().map(mix_section_id).is_some_and(|s| app.ui.mix_views.iter().any(|v| v == s)), + _ => false, + } +} + +fn ruler_id(label: &str) -> &'static str { + match label { + "Bars|Beats" => "bars_beats", + "Min:Secs" => "min_secs", + "Timecode" => "timecode", + "Feet+Frames" => "feet_frames", + "Samples" => "samples", + "Tempo" => "tempo", + "Meter" => "meter", + "Markers" => "markers", + "Key Signature" => "key", + "Chord Symbols" => "chords", + _ => "", + } +} + +fn mix_section_id(label: &str) -> &'static str { + match label { + "Inserts A-E" => "inserts_ae", + "Inserts F-J" => "inserts_fj", + "Sends A-E" => "sends_ae", + "Sends F-J" => "sends_fj", + "I/O" => "io", + "Track Color" => "color", + "Comments" => "comments", + "EQ Curve" => "eq_curve", + "Meters and Faders" => "meters", + "All" => "all", + "Minimal" => "minimal", + _ => "", + } +} + +/// Parameters implied by the menu item a command was reached through. +fn params_for(path: &str, id: &str) -> Value { + let leaf = path.rsplit(" > ").next().unwrap_or(""); + match id { + "view.ruler" => json!({"ruler": ruler_id(leaf)}), + "view.main_counter" => json!({"format": ruler_id(leaf)}), + "options.scrolling" => { + json!({"mode": match leaf { "No Scrolling" => "none", "After Playback" => "after_playback", "Continuous" => "continuous", "Center Playhead" => "center", _ => "page" }}) + } + "options.solo_mode" => { + json!({"mode": if leaf.starts_with("AFL") { "afl" } else if leaf.starts_with("PFL") { "pfl" } else { "sip" }, "xor": leaf.starts_with("X-OR")}) + } + "clip.rating" => json!({"rating": leaf.parse::().unwrap_or(0)}), + "track.change_width" => json!({"format": leaf}), + "view.mix_section" => json!({"section": mix_section_id(leaf)}), + "audiosuite.process" => json!({"process": AUDIOSUITE.iter().find(|(p, _)| *p == path).map_or("", |(_, x)| *x)}), + _ => json!({}), + } +} + +/// Menu-style invocation: may open a dialog. +pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { + if id == "audiosuite.process" { + let p = params_for(path, id); + app.ui.audiosuite = p.get("process").and_then(Value::as_str).map(str::to_string); + return; + } + if DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id) { + return; + } + let params = params_for(path, id); + let _ = app.run(id, params); +} + +/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled. +pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool { + DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id) +} + +/// Handle UI-layer commands. Returns None when `id` is not a UI command. +pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { + let toggle = |v: &mut bool| { + *v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v); + json!({"value": *v}) + }; + let r = match id { + "window.mix" => { + app.ui.window = MainWindow::Mix; + json!({"window": "mix"}) + } + "window.edit" => { + app.ui.window = MainWindow::Edit; + json!({"window": "edit"}) + } + "window.toggle_mix_edit" => { + app.ui.window = if app.ui.window == MainWindow::Edit { MainWindow::Mix } else { MainWindow::Edit }; + json!({"window": app.ui.window}) + } + "window.transport" => toggle(&mut app.ui.show_transport), + "window.big_counter" => toggle(&mut app.ui.show_big_counter), + "window.memory_locations" => toggle(&mut app.ui.show_memory_locations), + "window.undo_history" => toggle(&mut app.ui.show_undo_history), + "window.clip_list" | "window.clip_list_view" => toggle(&mut app.ui.show_clip_list), + "window.track_list" => toggle(&mut app.ui.show_tracks_list), + "window.narrow_mix" => toggle(&mut app.ui.narrow_mix), + "window.session_info" => toggle(&mut app.ui.show_session_info), + "window.about" => toggle(&mut app.ui.show_about), + "window.midi_editor" => toggle(&mut app.ui.show_midi_editor), + "window.universe" => toggle(&mut app.ui.show_universe), + "window.close" | "window.hide_floating" => { + app.ui.show_transport = false; + app.ui.show_big_counter = false; + app.ui.show_memory_locations = false; + app.ui.show_undo_history = false; + app.ui.show_session_info = false; + app.ui.plugin_windows.clear(); + app.ui.audiosuite = None; + json!({}) + } + "view.mix_section" => { + let sec = p.get("section").and_then(Value::as_str).unwrap_or(""); + match sec { + "all" => { + app.ui.mix_views = + ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(|s| s.to_string()).collect() + } + "minimal" => app.ui.mix_views.clear(), + "" => return Some(Err("`section` required".into())), + s => { + if app.ui.mix_views.iter().any(|v| v == s) { + app.ui.mix_views.retain(|v| v != s); + } else { + app.ui.mix_views.push(s.to_string()); + } + } + } + json!({"mix_views": app.ui.mix_views}) + } + "ui.new_tracks_dialog" => { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + json!({}) + } + "ui.bounce_dialog" => { + let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); + json!({}) + } + "ui.set" => match serde_json::to_value(&app.ui) { + Ok(mut cur) => { + if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) { + for (k, v) in patch { + obj.insert(k.clone(), v.clone()); + } + } + match serde_json::from_value(cur) { + Ok(ns) => { + app.ui = ns; + json!({"ui": app.ui}) + } + Err(e) => return Some(Err(format!("bad ui state: {e}"))), + } + } + Err(e) => return Some(Err(e.to_string())), + }, + _ => return None, + }; + Some(Ok(r)) +} + +/// Parity including UI-layer commands. +pub fn parity() -> Value { + let extra = ui_aliases(); + catalog::parity_with(&extra) +} diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs new file mode 100644 index 0000000..a988714 --- /dev/null +++ b/crates/ui-egui/src/mix_window.rs @@ -0,0 +1,394 @@ +//! The Mix window: one channel strip per track. + +use crate::theme::{Tokens, bold, regular, rgb}; +use crate::widgets::{db_text, fader, meter, pan_knob, pan_text, rec_toggle, selector_box, text_toggle}; +use crate::{SoundApp, panels}; +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_model::{Route, Track, TrackId, TrackKind}; + +pub fn strip_width(narrow: bool) -> f32 { + if narrow { 64.0 } else { 98.0 } +} + +pub fn show(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + if app.ui.show_tracks_list { + egui::Panel::left("mix_tracks_list") + .exact_size(168.0) + .frame(egui::Frame::NONE.fill(t.panel_bg)) + .show(ui, |ui| panels::tracks_and_groups(app, ui)); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| { + let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden).map(|x| x.id).collect(); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + for id in ids { + strip(app, ui, id); + } + }); + }); + }); +} + +fn section_label(ui: &Ui, r: Rect, text: &str) { + ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Color32::from_rgb(200, 200, 200)); +} + +fn route_name(app: &SoundApp, r: &Route) -> String { + match r { + Route::None => "no output".into(), + Route::Main => "Out 1-2".into(), + Route::Bus(b) => app.engine.session().bus(*b).map_or_else(|| "bus?".into(), |b| b.name.clone()), + Route::Hardware(h) => h.clone(), + } +} + +fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { + let t = Tokens::DARK; + let Some(track) = app.engine.session().track(id).cloned() else { return }; + let narrow = app.ui.narrow_mix; + let w = strip_width(narrow); + let h = ui.available_height().max(560.0); + let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + let selected = app.engine.session().edit.selected_tracks.contains(&id); + ui.painter().rect_filled(r, 0.0, if selected { Color32::from_rgb(54, 58, 64) } else { t.strip_bg }); + let mut y = r.min.y + 4.0; + let inner_w = w - 8.0; + let x0 = r.min.x + 4.0; + let views = app.ui.mix_views.clone(); + let has = |v: &str| views.iter().any(|x| x == v); + // Inserts A-E. + if has("inserts_ae") { + let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); + ui.painter().rect_filled(sec, 2.0, t.strip_section); + section_label(ui, sec, "INSERTS A-E"); + for slot in 0..5 { + let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + slot as f32 * 17.0), vec2(inner_w - 4.0, 15.0)); + insert_slot(app, ui, &track, slot, sr); + } + y = sec.max.y + 4.0; + } + if has("sends_ae") { + let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); + ui.painter().rect_filled(sec, 2.0, t.strip_section); + section_label(ui, sec, "SENDS A-E"); + for slot in 0..5 { + let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + slot as f32 * 17.0), vec2(inner_w - 4.0, 15.0)); + send_slot(app, ui, &track, slot, sr); + } + y = sec.max.y + 4.0; + } + if has("io") { + let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0)); + ui.painter().rect_filled(sec, 2.0, t.strip_section); + section_label(ui, sec, "I / O"); + let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0)); + let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); + let input = + if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }; + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r)); + let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text); + egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r)); + let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); + egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false)); + // Automation mode. + let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); + ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(am_r)); + let mode = track.mixer.automation_mode; + let col = if mode == soundcraft_model::AutomationMode::Read { + t.auto_read + } else if mode == soundcraft_model::AutomationMode::Off { + t.text_dim + } else { + t.auto_write + }; + let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col); + egui::Popup::menu(&resp).show(|ui| { + for m in soundcraft_model::AutomationMode::ALL { + if ui.selectable_label(m == mode, m.label()).clicked() { + let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + } + } + }); + y = sec.max.y + 4.0; + } + // Group selector. + let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or("no group".to_string(), |g| g.name.clone()); + let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr)); + let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); + y += 22.0; + // Pan. + if !track.mixer.pan.is_empty() && track.kind != TrackKind::Vca { + let n = track.mixer.pan.len().min(2); + let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 }; + for i in 0..n { + let cx = if n == 2 { x0 + inner_w * (0.25 + 0.5 * i as f32) } else { x0 + inner_w * 0.5 }; + let kr = Rect::from_center_size(pos2(cx, y + ks * 0.5), vec2(ks, ks)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr)); + let mut v = track.mixer.pan.get(i).copied().unwrap_or(0.0); + let before = v; + let resp = pan_knob(&mut c, ks, &mut v, "Pan (drag; double-click centres)"); + if (v - before).abs() > f32::EPSILON { + let _ = app.engine.execute("mix.pan", &json!({"track": id.0, "pan": v, "index": i})); + } + if resp.drag_stopped() { + app.engine.push_undo("Pan", app.engine.session_arc()); + } + let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); + ui.painter().rect_filled(pr, 1.0, t.counter_bg); + ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text); + } + y += ks + 20.0; + } + // Rec / Input / Solo / Mute. + let bw = (inner_w - 6.0) / 2.0; + let mut row = ui.new_child( + egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0))).layout(egui::Layout::left_to_right(egui::Align::Min)), + ); + row.spacing_mut().item_spacing.x = 6.0; + if track.kind.has_playlist() { + if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, "Input monitoring").clicked() { + let _ = app.run("mix.input_monitor", json!({"track": id.0})); + } + if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, "Record enable").clicked() { + let _ = app.run("mix.record_arm", json!({"track": id.0})); + } + } + y += 22.0; + let mut row = ui.new_child( + egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(x0, y), vec2(inner_w, 20.0))).layout(egui::Layout::left_to_right(egui::Align::Min)), + ); + row.spacing_mut().item_spacing.x = 6.0; + if track.kind != TrackKind::Master { + if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, "Solo").clicked() { + let _ = app.run("mix.solo", json!({"track": id.0})); + } + if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, "Mute").clicked() { + let _ = app.run("mix.mute", json!({"track": id.0})); + } + } + y += 26.0; + // Fader + meter. + let bottom_h = 64.0; + let fh = (r.max.y - bottom_h - y).max(120.0); + let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * 0.6, fh)); + if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) { + if app.gesture.is_none() { + app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db }); + app.ui.status = String::new(); + } + let before = app.engine.session_arc(); + let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": db})); + let _ = before; + } + if !ui.input(|i| i.pointer.any_down()) + && let Some(crate::Gesture::Fader { track: ft, start_db }) = app.gesture.clone() + && ft == id + { + app.gesture = None; + // Collapse the drag into a single undo step. + let end = app.engine.session().track(id).map_or(start_db, |t| t.mixer.volume_db); + if (end - start_db).abs() > 0.001 { + let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": start_db})); + let _ = app.run("mix.volume", json!({"track": id.0, "db": end})); + } + } + let md = app.meters.get(&id).copied().unwrap_or_default(); + let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0)); + let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) }; + if chans >= 2 { + let mw = mr.width() / 2.0 - 1.0; + meter(ui, Rect::from_min_size(mr.min, vec2(mw, mr.height())), md.level[0], md.hold[0], md.clip); + meter(ui, Rect::from_min_size(pos2(mr.min.x + mw + 2.0, mr.min.y), vec2(mw, mr.height())), md.level[1], md.hold[1], md.clip); + } else { + meter(ui, mr, md.level[0], md.hold[0], md.clip); + } + if md.gr > 0.1 { + ui.painter().text(pos2(mr.center().x, mr.min.y - 3.0), Align2::CENTER_BOTTOM, format!("-{:.0}", md.gr), regular(8.0), t.meter_yellow); + } + // Volume readout and name. + let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0)); + ui.painter().rect_filled(vr, 1.0, t.counter_bg); + ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text); + let kind = match track.kind { + TrackKind::Audio => "audio", + TrackKind::Aux => "aux", + TrackKind::Master => "master", + TrackKind::Midi => "midi", + TrackKind::Instrument => "inst", + TrackKind::Vca => "vca", + TrackKind::Folder => "folder", + }; + ui.painter().text(pos2(vr.center().x, vr.max.y + 9.0), Align2::CENTER_CENTER, kind, regular(9.5), t.text_dim); + let nr = Rect::from_min_size(pos2(x0, r.max.y - 26.0), vec2(inner_w, 18.0)); + ui.painter().rect( + nr, + CornerRadius::same(2), + if selected { t.name_field_sel } else { t.name_field }, + Stroke::new(1.0, Color32::BLACK), + StrokeKind::Inside, + ); + ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark); + let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click()); + if nresp.clicked() { + let _ = app.run("edit.select", json!({"tracks": [id.0]})); + } + if nresp.double_clicked() { + app.dialogs.open_rename_track(id, &track.name); + } + if app.ui.mix_views.iter().any(|v| v == "color") { + ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y - 5.0), vec2(w, 5.0)), 0.0, rgb(track.color)); + } + ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); +} + +fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { + let t = Tokens::DARK; + let id = track.id; + let ins = track.mixer.inserts.get(slot).cloned().flatten(); + let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); + let fill = match &ins { + Some(i) if i.bypass => Color32::from_rgb(70, 56, 30), + Some(_) => Color32::from_rgb(52, 62, 80), + None => t.slot_bg, + }; + ui.painter().rect( + r, + CornerRadius::same(2), + if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, + Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), + StrokeKind::Inside, + ); + let label = ins.as_ref().and_then(|i| soundcraft_dsp::plugin_info(&i.plugin)).map_or("", |p| p.short_name); + if label.is_empty() { + ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); + } else { + ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, label, regular(10.5), t.text); + } + if resp.clicked() && ins.is_some() + && !app.ui.plugin_windows.contains(&(id, slot)) { + app.ui.plugin_windows.push((id, slot)); + } + let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") }; + let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; + popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); +} + +pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) { + if occupied { + if ui.button("Bypass").clicked() { + let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + } + if ui.button("no insert").clicked() { + let _ = app.run("mix.insert_remove", json!({"track": id.0, "slot": slot})); + } + ui.separator(); + } + let mut cats: Vec = Vec::new(); + for p in soundcraft_dsp::plugins() { + if !p.is_instrument && !cats.contains(&p.category) { + cats.push(p.category); + } + } + for c in cats { + ui.menu_button(format!("{c:?}"), |ui| { + for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) { + if ui.button(p.name).clicked() { + let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); + } + } + }); + } +} + +fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { + let t = Tokens::DARK; + let id = track.id; + let snd = track.mixer.sends.get(slot).cloned().flatten(); + let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag()); + let fill = if snd.is_some() { Color32::from_rgb(46, 66, 56) } else { t.slot_bg }; + ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), StrokeKind::Inside); + match &snd { + Some(s) => { + let name = route_name(app, &s.target); + // Level bar. + let k = soundcraft_model::fader_db_to_pos(s.level_db); + ui.painter().rect_filled( + Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 3.0), pos2(r.min.x + 1.0 + (r.width() - 2.0) * k, r.max.y - 1.0)), + 0.0, + t.counter_text, + ); + ui.painter().with_clip_rect(r).text( + r.center() - vec2(0.0, 1.0), + Align2::CENTER_CENTER, + name, + regular(10.0), + if s.mute { t.mute } else { t.text }, + ); + if resp.dragged() { + let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0); + let _ = app.engine.execute("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db})); + } + if resp.double_clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } + let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db))); + } + None => { + ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); + } + } + let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) }; + popup.show(|ui| { + if snd.is_some() && ui.button("no send").clicked() { + let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot})); + } + let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); + for b in busses { + if ui.button(&b).clicked() { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b})); + } + } + ui.separator(); + if ui.button("new bus...").clicked() { + let n = app.engine.session().busses.len() + 1; + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + } + }); +} + +fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { + let key = if input { "input" } else { "output" }; + let cmd = if input { "track.input" } else { "track.output" }; + if ui.button(if input { "no input" } else { "no output" }).clicked() { + let _ = app.run(cmd, json!({"track": id.0, key: "none"})); + } + if !input && ui.button("Out 1-2").clicked() { + let _ = app.run(cmd, json!({"track": id.0, key: "main"})); + } + if input { + for i in 1..=8 { + if ui.button(format!("In {i}")).clicked() { + let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")})); + } + } + } + ui.separator(); + let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); + ui.menu_button("bus", |ui| { + for b in &busses { + if ui.button(b).clicked() { + let _ = app.run(cmd, json!({"track": id.0, key: b})); + } + } + if ui.button("new bus...").clicked() { + let n = busses.len() + 1; + let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")})); + } + }); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs new file mode 100644 index 0000000..01aa672 --- /dev/null +++ b/crates/ui-egui/src/panels.rs @@ -0,0 +1,359 @@ +//! Side panels (Tracks, Groups, Clips) and floating windows. + +use crate::SoundApp; +use crate::theme::{Tokens, bold, mono, regular, rgb}; +use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_time::{TimeFormat, format_position}; + +fn panel_header(ui: &mut Ui, title: &str) { + let t = Tokens::DARK; + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.panel_bg2); + ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, title, bold(11.5), t.header_text); + ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); +} + +pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let total = ui.available_height(); + panel_header(ui, "TRACKS"); + let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { + let s = app.engine.session(); + s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() + }; + egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| { + for (id, name, shown, color, sel) in tracks { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); + if sel { + ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96)); + } + let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0)); + let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click()); + ui.painter().circle( + dot.center(), + 3.5, + if shown { Color32::from_rgb(200, 200, 200) } else { Color32::TRANSPARENT }, + Stroke::new(1.0, t.text_dim), + ); + if dresp.clicked() { + let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown})); + } + ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color)); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim }); + if resp.clicked() { + let _ = app.run("edit.select", json!({"tracks": [id]})); + } + } + }); + ui.add_space(4.0); + panel_header(ui, "GROUPS"); + let groups: Vec<(u64, char, String, bool, [u8; 3])> = + app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect(); + egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).show(ui, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover()); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "", regular(11.5).clone(), t.text); + for (id, letter, name, active, color) in groups { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); + if active { + ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color)); + } + ui.painter().text( + pos2(r.min.x + 11.0, r.center().y), + Align2::CENTER_CENTER, + letter.to_string(), + bold(10.0), + if active { t.text_dark } else { t.text_dim }, + ); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text); + if resp.clicked() { + let _ = app.run("track.group_toggle", json!({"group": id})); + } + if resp.double_clicked() { + let members: Vec = + app.engine.session().groups.iter().find(|g| g.id.0 == id).map(|g| g.members.iter().map(|m| m.0).collect()).unwrap_or_default(); + let _ = app.run("edit.select", json!({"tracks": members})); + } + } + }); +} + +pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + panel_header(ui, "CLIPS"); + let items: Vec<(Option, String, bool, [u8; 3])> = { + let s = app.engine.session(); + let mut v: Vec<(Option, String, bool, [u8; 3])> = Vec::new(); + // Whole files first (bold), then clips on tracks. + for src in &s.sources { + v.push((None, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150])); + } + for tr in &s.tracks { + for c in tr.clips() { + v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color))); + } + } + v + }; + let selected: Vec = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect(); + egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| { + for (id, name, whole, color) in items { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); + if id.is_some_and(|i| selected.contains(&i)) { + ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96)); + } + ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color)); + ui.painter().with_clip_rect(r).text( + pos2(r.min.x + 22.0, r.center().y), + Align2::LEFT_CENTER, + &name, + if whole { bold(11.0) } else { regular(11.0) }, + t.text, + ); + if resp.clicked() + && let Some(i) = id + { + let _ = app.run("edit.select", json!({"clips": [i]})); + } + } + }); +} + +/// Floating windows. +pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { + transport_window(app, ctx); + memory_locations(app, ctx); + big_counter(app, ctx); + undo_history(app, ctx); + plugin_windows(app, ctx); + about(app, ctx); + session_info(app, ctx); +} + +fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_transport; + if !open { + return; + } + egui::Window::new("Transport").open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| { + ui.horizontal(|ui| { + for (icon, cmd) in [ + ("rtz", "transport.rtz"), + ("rewind", "transport.rewind"), + ("stop", "transport.stop"), + ("play", "transport.play"), + ("ffwd", "transport.fast_forward"), + ("end", "transport.go_to_end"), + ("record", "transport.record"), + ] { + if crate::widgets::icon_button(ui, vec2(34.0, 26.0), icon, false, cmd).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + let s = app.engine.session(); + let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + ui.label(egui::RichText::new(txt).font(mono(20.0)).color(Tokens::DARK.counter_text)); + }); + app.ui.show_transport = open; +} + +fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_big_counter; + if !open { + return; + } + egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| { + let s = app.engine.session(); + let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let r = ui.available_rect_before_wrap(); + ui.painter().rect_filled(r, 4.0, Color32::BLACK); + ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), Tokens::DARK.counter_text); + }); + app.ui.show_big_counter = open; +} + +fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_memory_locations; + if !open { + return; + } + egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(420.0, 260.0)).show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("+ New").clicked() { + let _ = app.run("markers.add", json!({})); + } + }); + let markers: Vec<(u32, String, i64, String)> = { + let s = app.engine.session(); + s.markers + .iter() + .map(|m| { + (m.number, m.name.clone(), m.start, format_position(m.start, TimeFormat::BarsBeats, s.sample_rate, &s.tempo, s.frame_rate, 0)) + }) + .collect() + }; + egui::Grid::new("mem_grid").striped(true).num_columns(4).show(ui, |ui| { + ui.label(egui::RichText::new("#").strong()); + ui.label(egui::RichText::new("Name").strong()); + ui.label(egui::RichText::new("Location").strong()); + ui.label(""); + ui.end_row(); + for (n, name, _, loc) in markers { + if ui.button(n.to_string()).clicked() { + let _ = app.run("markers.recall", json!({"number": n})); + } + ui.label(name); + ui.label(loc); + if ui.small_button("✕").clicked() { + let _ = app.run("markers.delete", json!({"number": n})); + } + ui.end_row(); + } + }); + }); + app.ui.show_memory_locations = open; +} + +fn undo_history(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_undo_history; + if !open { + return; + } + egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| { + let hist = app.engine.undo_history(); + let n = hist.len(); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, h) in hist.iter().enumerate() { + if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { + for _ in 0..(n - i - 1) { + let _ = app.run("edit.undo", json!({})); + } + } + } + if let Some(r) = app.engine.redo_label() { + ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::DARK.text_dim)); + } + }); + }); + app.ui.show_undo_history = open; +} + +fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { + let wins = app.ui.plugin_windows.clone(); + let mut keep = Vec::new(); + for (tid, slot) in wins { + let Some((tname, ins)) = + app.engine.session().track(tid).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten().map(|i| (t.name.clone(), i))) + else { + continue; + }; + let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) else { continue }; + let mut open = true; + egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name)) + .id(egui::Id::new(("plugin", tid.0, slot))) + .open(&mut open) + .default_width(340.0) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim)); + let label = if ins.bypass { "BYPASSED" } else { "Bypass" }; + if ui.button(label).clicked() { + let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot})); + } + }); + if info.id == "eq_7band" || info.id == "eq_1band" { + eq_curve(ui, info.id, &ins); + } + egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| { + for p in info.params { + let v = ins.params.get(p.id).copied().unwrap_or(p.default); + ui.label(p.name); + let mut x = v; + let resp = if !p.choices.is_empty() { + let mut idx = x.round().max(0.0) as usize; + let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id)) + .selected_text(p.choices.get(idx).copied().unwrap_or("?")) + .show_ui(ui, |ui| { + for (i, c) in p.choices.iter().enumerate() { + ui.selectable_value(&mut idx, i, *c); + } + }); + x = idx as f32; + r.response + } else { + let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false); + if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 { + sl = sl.logarithmic(true); + } + ui.add(sl) + }; + if (x - v).abs() > f32::EPSILON { + let _ = app.engine.execute("mix.insert_param", &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x})); + } + let _ = resp; + ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); + ui.end_row(); + } + }); + }); + if open { + keep.push((tid, slot)); + } + } + app.ui.plugin_windows = keep; +} + +fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) { + let t = Tokens::DARK; + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().max(300.0), 110.0), Sense::hover()); + ui.painter().rect_filled(r, 4.0, Color32::from_rgb(14, 18, 22)); + let n = 160; + let freqs: Vec = (0..n).map(|i| 20.0 * 1000f32.powf(i as f32 / (n - 1) as f32)).collect(); + let params: Vec<(&str, f32)> = ins.params.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + let resp = if id == "eq_7band" { + soundcraft_dsp::eq7_response(¶ms, &freqs, 48_000.0) + } else { + soundcraft_dsp::eq1_response(¶ms, &freqs, 48_000.0) + }; + for db in [-12.0f32, 0.0, 12.0] { + let y = r.center().y - db / 18.0 * r.height() * 0.5; + ui.painter().line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, Color32::from_rgb(40, 50, 60))); + } + let pts: Vec = resp + .iter() + .enumerate() + .map(|(i, db)| pos2(r.min.x + r.width() * i as f32 / (n - 1) as f32, r.center().y - db.clamp(-18.0, 18.0) / 18.0 * r.height() * 0.5)) + .collect(); + ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, t.counter_text))); +} + +fn about(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_about; + if !open { + return; + } + egui::Window::new("About SoundCraft").open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| { + ui.label(egui::RichText::new("SoundCraft").font(bold(22.0))); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.label("An open-source digital audio workstation from the ArtCraft team."); + ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui."); + ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft"); + ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft"); + }); + app.ui.show_about = open; +} + +fn session_info(app: &mut SoundApp, ctx: &egui::Context) { + let mut open = app.ui.show_session_info; + if !open { + return; + } + egui::Window::new("Session Info").open(&mut open).default_size(vec2(480.0, 360.0)).show(ctx, |ui| { + let text = soundcraft_engine::inspect::session_text(&app.engine); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.label(egui::RichText::new(text).font(mono(11.0))); + }); + }); + app.ui.show_session_info = open; +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs new file mode 100644 index 0000000..bddb1ce --- /dev/null +++ b/crates/ui-egui/src/shortcuts.rs @@ -0,0 +1,156 @@ +//! Keyboard shortcuts. Most come from the command registry's `shortcut` strings; a few +//! context-sensitive keys (Space, Enter, F-keys, nudge, Tab) are handled here. + +use crate::{MainWindow, SoundApp}; +use egui::{Key, Modifiers}; +use serde_json::json; + +/// Parse "Cmd+Shift+X" style strings. Returns None for descriptive ones ("F1-F4", "Plus/Minus"). +pub fn parse(s: &str) -> Option<(Modifiers, Key)> { + let mut m = Modifiers::NONE; + let mut key = None; + for part in s.split('+') { + match part.trim() { + "Cmd" => m.command = true, + "Shift" => m.shift = true, + "Alt" => m.alt = true, + "Ctrl" => m.ctrl = true, + "" => {} + k => { + if key.is_some() { + return None; + } + key = Some(match k { + "Space" => Key::Space, + "Enter" => Key::Enter, + "Home" => Key::Home, + "End" => Key::End, + "Tab" => Key::Tab, + "[" => Key::OpenBracket, + "]" => Key::CloseBracket, + "," => Key::Comma, + "." => Key::Period, + "/" => Key::Slash, + "'" => Key::Quote, + "=" => Key::Equals, + other => Key::from_name(other)?, + }); + } + } + } + key.map(|k| (m, k)) +} + +fn mods_match(want: Modifiers, got: Modifiers) -> bool { + want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) +} + +pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() { + return; + } + let events: Vec<(Key, Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)), + _ => None, + }) + .collect() + }); + for (key, mods) in events { + if fixed(app, key, mods) { + continue; + } + let hit = + soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods))); + if let Some(c) = hit { + let id = c.id; + if crate::menus::invoke_shortcut_dialog(app, id) { + continue; + } + let _ = app.run(id, json!({})); + } + } +} + +/// Keys with context-dependent meaning. Returns true when handled. +fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { + let plain = !m.command && !m.alt && !m.ctrl && !m.shift; + match key { + Key::Space if !m.command && !m.alt => { + let id = if m.shift { "transport.half_speed" } else { "transport.toggle" }; + let _ = app.run(id, json!({})); + true + } + Key::Space if m.command => { + let _ = app.run("transport.record", json!({})); + true + } + Key::Equals if m.command => { + let _ = app.run("window.toggle_mix_edit", json!({})); + true + } + Key::Enter if plain => { + let _ = app.run("markers.add", json!({})); + true + } + Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => { + let mode = match key { + Key::F1 => "shuffle", + Key::F2 => "slip", + Key::F3 => "spot", + _ => "grid", + }; + let _ = app.run("edit.mode", json!({"mode": mode})); + true + } + Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => { + let tool = match key { + Key::F5 => "zoom", + Key::F6 => "trim", + Key::F7 => "selector", + Key::F8 => "grabber", + Key::F9 => "scrubber", + _ => "pencil", + }; + let _ = app.run("edit.tool", json!({"tool": tool})); + true + } + Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => { + let dir = if key == Key::Plus { 1 } else { -1 }; + let _ = app.run("edit.nudge", json!({"direction": dir})); + true + } + Key::Tab if plain || m.alt => { + // Tab to next clip boundary (or transient when enabled). + let s = app.engine.session(); + let at = s.edit.selection.start; + let back = m.alt; + let mut edges: Vec = s + .tracks + .iter() + .filter(|t| s.edit.selected_tracks.contains(&t.id)) + .flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()])) + .collect(); + edges.sort_unstable(); + let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() }; + if let Some(n) = next { + let _ = app.run("transport.locate", json!({"at": n})); + } + true + } + Key::ArrowUp | Key::ArrowDown if m.ctrl => { + let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" }; + let _ = app.run(id, json!({})); + true + } + Key::R | Key::T if plain => { + // Zoom out / in on the timeline (Commands Focus style). + let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }; + let _ = app.run(id, json!({})); + true + } + _ => false, + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 0000000..8cbd5f9 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,174 @@ +//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours. + +use egui::{Color32, FontFamily, FontId}; + +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub window_bg: Color32, + pub panel_bg: Color32, + pub panel_bg2: Color32, + pub toolbar_bg: Color32, + pub toolbar_group: Color32, + pub border: Color32, + pub border_light: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_dark: Color32, + pub header_text: Color32, + pub counter_bg: Color32, + pub counter_text: Color32, + pub counter_label: Color32, + pub accent: Color32, + pub accent_dark: Color32, + pub mode_on: Color32, + pub mode_on_text: Color32, + pub mode_off_text: Color32, + pub button: Color32, + pub button_hi: Color32, + pub button_border: Color32, + pub name_field: Color32, + pub name_field_sel: Color32, + pub ruler_bg: Color32, + pub ruler_label_bg: Color32, + pub ruler_text: Color32, + pub ruler_tick: Color32, + pub tempo_ruler: Color32, + pub meter_ruler: Color32, + pub marker_ruler: Color32, + pub playlist_bg: Color32, + pub playlist_alt: Color32, + pub grid_line: Color32, + pub bar_line: Color32, + pub selection: Color32, + pub playhead: Color32, + pub insertion: Color32, + pub solo: Color32, + pub mute: Color32, + pub rec: Color32, + pub input: Color32, + pub auto_read: Color32, + pub auto_write: Color32, + pub auto_touch: Color32, + pub auto_latch: Color32, + pub meter_green: Color32, + pub meter_yellow: Color32, + pub meter_red: Color32, + pub fader_track: Color32, + pub strip_bg: Color32, + pub strip_section: Color32, + pub slot_bg: Color32, + pub automation_line: Color32, +} + +impl Tokens { + pub const DARK: Tokens = Tokens { + window_bg: Color32::from_rgb(30, 30, 30), + panel_bg: Color32::from_rgb(37, 37, 38), + panel_bg2: Color32::from_rgb(44, 44, 45), + toolbar_bg: Color32::from_rgb(30, 30, 31), + toolbar_group: Color32::from_rgb(24, 24, 25), + border: Color32::from_rgb(14, 14, 14), + border_light: Color32::from_rgb(64, 64, 66), + text: Color32::from_rgb(214, 214, 214), + text_dim: Color32::from_rgb(150, 150, 152), + text_dark: Color32::from_rgb(16, 16, 16), + header_text: Color32::from_rgb(196, 196, 198), + counter_bg: Color32::from_rgb(4, 4, 4), + counter_text: Color32::from_rgb(104, 220, 120), + counter_label: Color32::from_rgb(200, 200, 200), + accent: Color32::from_rgb(64, 132, 196), + accent_dark: Color32::from_rgb(40, 92, 146), + mode_on: Color32::from_rgb(92, 196, 108), + mode_on_text: Color32::from_rgb(10, 30, 12), + mode_off_text: Color32::from_rgb(98, 196, 112), + button: Color32::from_rgb(58, 58, 60), + button_hi: Color32::from_rgb(78, 78, 80), + button_border: Color32::from_rgb(20, 20, 20), + name_field: Color32::from_rgb(208, 208, 208), + name_field_sel: Color32::from_rgb(232, 232, 232), + ruler_bg: Color32::from_rgb(40, 40, 41), + ruler_label_bg: Color32::from_rgb(40, 40, 41), + ruler_text: Color32::from_rgb(178, 178, 180), + ruler_tick: Color32::from_rgb(96, 96, 98), + tempo_ruler: Color32::from_rgb(52, 128, 88), + meter_ruler: Color32::from_rgb(44, 108, 140), + marker_ruler: Color32::from_rgb(46, 46, 48), + playlist_bg: Color32::from_rgb(36, 36, 37), + playlist_alt: Color32::from_rgb(40, 40, 41), + grid_line: Color32::from_rgb(52, 52, 54), + bar_line: Color32::from_rgb(66, 66, 70), + selection: Color32::from_rgba_premultiplied(40, 70, 110, 110), + playhead: Color32::from_rgb(232, 60, 50), + insertion: Color32::from_rgb(230, 230, 230), + solo: Color32::from_rgb(222, 196, 52), + mute: Color32::from_rgb(232, 148, 40), + rec: Color32::from_rgb(214, 52, 46), + input: Color32::from_rgb(70, 170, 90), + auto_read: Color32::from_rgb(96, 200, 110), + auto_write: Color32::from_rgb(220, 70, 60), + auto_touch: Color32::from_rgb(230, 170, 50), + auto_latch: Color32::from_rgb(160, 120, 220), + meter_green: Color32::from_rgb(60, 200, 80), + meter_yellow: Color32::from_rgb(230, 210, 60), + meter_red: Color32::from_rgb(230, 50, 40), + fader_track: Color32::from_rgb(12, 12, 12), + strip_bg: Color32::from_rgb(46, 46, 48), + strip_section: Color32::from_rgb(36, 36, 38), + slot_bg: Color32::from_rgb(28, 28, 29), + automation_line: Color32::from_rgb(240, 240, 240), + }; + + pub fn get(_ctx: &egui::Context) -> Tokens { + Tokens::DARK + } +} + +/// Track colours tinted for clip bodies. +pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { + let [r, g, b] = rgb; + let k = if selected { 0.95 } else { 0.62 }; + let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); + let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8); + let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) }; + (body, bar, wave) +} + +pub fn rgb(c: [u8; 3]) -> Color32 { + Color32::from_rgb(c[0], c[1], c[2]) +} + +pub fn bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("bold".into())) +} + +pub fn regular(size: f32) -> FontId { + FontId::new(size, FontFamily::Proportional) +} + +pub fn mono(size: f32) -> FontId { + FontId::new(size, FontFamily::Monospace) +} + +/// Apply visuals to the context. +pub fn apply(ctx: &egui::Context) { + let t = Tokens::DARK; + let mut v = egui::Visuals::dark(); + v.panel_fill = t.panel_bg; + v.window_fill = t.panel_bg2; + v.extreme_bg_color = t.slot_bg; + v.faint_bg_color = t.panel_bg2; + v.selection.bg_fill = t.accent; + v.widgets.inactive.weak_bg_fill = t.button; + v.widgets.inactive.bg_fill = t.button; + v.widgets.hovered.weak_bg_fill = t.button_hi; + v.widgets.noninteractive.fg_stroke.color = t.text; + v.widgets.inactive.fg_stroke.color = t.text; + v.window_corner_radius = egui::CornerRadius::same(6); + v.menu_corner_radius = egui::CornerRadius::same(4); + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(6.0, 4.0); + s.spacing.button_padding = egui::vec2(6.0, 2.0); + s.interaction.tooltip_delay = 0.4; + }); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs new file mode 100644 index 0000000..fe932e0 --- /dev/null +++ b/crates/ui-egui/src/toolbar.rs @@ -0,0 +1,385 @@ +//! The Edit window toolbar. + +use crate::theme::{Tokens, bold, mono, regular}; +use crate::widgets::icon_button; +use crate::{SoundApp, icons}; +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; +use soundcraft_model::{EditMode, Tool}; +use soundcraft_time::{TimeFormat, format_length, format_position}; + +pub const HEIGHT: f32 = 78.0; + +fn group(ui: &mut Ui, w: f32, h: f32, add: impl FnOnce(&mut Ui, Rect)) { + let t = Tokens::DARK; + let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, Color32::from_rgb(8, 8, 8)), StrokeKind::Inside); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(5.0)).layout(egui::Layout::left_to_right(egui::Align::Min))); + add(&mut child, r); +} + +pub fn show(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let full = ui.max_rect(); + ui.painter().rect_filled(full, 0.0, t.toolbar_bg); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 8.0; + edit_modes(app, ui); + zoom_group(app, ui); + tools(app, ui); + counters(app, ui); + grid_nudge(app, ui); + transport(app, ui); + tempo_meter(app, ui); + output_meter(app, ui); + }); +} + +fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let cur = app.engine.session().edit.edit_mode; + group(ui, 112.0, 52.0, |ui, r| { + let inner = r.shrink(6.0); + let cw = inner.width() / 2.0; + let ch = inner.height() / 2.0; + let modes = [ + (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), + (EditMode::Spot, 1, 0, "SPOT", "F3"), + (EditMode::Slip, 0, 1, "SLIP", "F2"), + (EditMode::Grid, 1, 1, "GRID", "F4"), + ]; + for (m, cx, cy, label, key) in modes { + let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); + let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); + let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); + ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { Color32::from_rgb(20, 36, 22) }); + let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; + ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text }); + if resp.clicked() { + let id = match m { + EditMode::Grid if cur == EditMode::Grid => "relative_grid", + EditMode::Shuffle => "shuffle", + EditMode::Spot => "spot", + EditMode::Slip => "slip", + _ => "grid", + }; + let _ = app.run("edit.mode", json!({"mode": id})); + } + resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase())); + } + }); +} + +fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { + group(ui, 128.0, 52.0, |ui, _| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if icon_button(ui, vec2(28.0, 22.0), "minus", false, "Zoom out (Cmd+[)").clicked() { + let _ = app.run("view.zoom_out", json!({})); + } + if icon_button(ui, vec2(30.0, 22.0), "wave", false, "Waveform zoom in (Cmd+Alt+])").clicked() { + let _ = app.run("view.waveform_zoom", json!({"factor": 2.0})); + } + if icon_button(ui, vec2(28.0, 22.0), "plus", false, "Zoom in (Cmd+])").clicked() { + let _ = app.run("view.zoom_in", json!({})); + } + if icon_button(ui, vec2(28.0, 22.0), "zoom", false, "Fill window with session (Alt+A)").clicked() { + let w = ui.ctx().content_rect().width() - 420.0; + let _ = app.run("view.zoom_fit", json!({"width_px": w})); + } + }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for i in 1..=5 { + let r = crate::widgets::text_toggle( + ui, + vec2(22.0, 18.0), + &i.to_string(), + false, + Color32::GRAY, + "Zoom preset (click to recall, Cmd-click to store)", + ); + if r.clicked() { + let store = ui.input(|x| x.modifiers.command); + let _ = app.run("view.zoom_preset", json!({"preset": i, "store": store})); + } + } + }); + }); + }); +} + +fn tools(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let cur = app.engine.session().edit.tool; + group(ui, 268.0, 52.0, |ui, r| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 3.0); + let row = ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (tool, icon) in [ + (Tool::Zoom, "zoom"), + (Tool::Trim, "trim"), + (Tool::Selector, "selector"), + (Tool::Grabber, "grabber"), + (Tool::Scrubber, "scrubber"), + (Tool::Pencil, "pencil"), + ] { + let smart_member = cur == Tool::Smart && matches!(tool, Tool::Trim | Tool::Selector | Tool::Grabber); + let tip = format!("{}{}", tool.label(), tool.fkey().map(|k| format!(" ({k})")).unwrap_or_default()); + if icon_button(ui, vec2(36.0, 26.0), icon, cur == tool || smart_member, &tip).clicked() { + let _ = app.run("edit.tool", json!({"tool": tool.id()})); + } + } + }); + // Smart tool bracket above Trim/Selector/Grabber. + let rr = row.response.rect; + let bx0 = rr.min.x + 38.0; + let bx1 = bx0 + 38.0 * 3.0 - 2.0; + let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0)); + let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click()); + ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { Color32::from_rgb(64, 64, 66) }); + if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() { + let _ = app.run("edit.tool", json!({"tool": "smart"})); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let e = app.engine.session().edit.clone(); + let toggles: [(&str, &str, bool, &str); 5] = [ + ("options.tab_to_transient", "selector", e.tab_to_transient, "Tab to Transient"), + ("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"), + ("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"), + ("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"), + ("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"), + ]; + for (id, icon, on, tip) in toggles { + if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() { + let _ = app.run(id, json!({})); + } + } + }); + }); + }); +} + +fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { + let s = app.engine.session(); + format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) +} + +fn counters(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let (main_fmt, sub_fmt, sel) = { + let s = app.engine.session(); + (s.edit.main_counter, s.edit.sub_counter.unwrap_or(TimeFormat::Samples), s.edit.selection) + }; + let pos = app.position(); + let (r, _) = ui.allocate_exact_size(vec2(318.0, 64.0), Sense::hover()); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0)); + let txt = fmt_pos(app, pos, main_fmt); + ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text); + let resp = ui.interact(main_r, ui.id().with("main_counter"), Sense::click()); + icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim); + egui::Popup::menu(&resp).show(|ui| { + for f in TimeFormat::ALL { + if ui.selectable_label(main_fmt == f, f.label()).clicked() { + let _ = app.run("view.main_counter", json!({"format": f.id()})); + } + } + }); + // Start / End / Length. + let s = app.engine.session(); + let rows = [ + ("Start", fmt_pos(app, sel.start, main_fmt)), + ("End", fmt_pos(app, sel.end, main_fmt)), + ("Length", format_length(sel.start, sel.len(), main_fmt, s.sample_rate, &s.tempo, s.frame_rate)), + ]; + for (i, (label, val)) in rows.iter().enumerate() { + let y = r.min.y + 10.0 + i as f32 * 13.0; + ui.painter().text(pos2(r.min.x + 214.0, y), Align2::RIGHT_CENTER, *label, regular(11.0), t.counter_label); + ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, val, mono(11.0), t.counter_text); + } + // Sub counter row. + let y = r.max.y - 11.0; + ui.painter() + .line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, Color32::from_rgb(40, 40, 40))); + ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label); + ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text); + let st = if app.engine.is_dirty() { "modified" } else { "saved" }; + let dev = app.player.as_ref().map_or("no audio engine", |p| if p.silent { "silent clock" } else { "audio on" }); + ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim); +} + +fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let (grid, nudge, lines) = { + let e = &app.engine.session().edit; + (e.grid, e.nudge, e.grid_lines) + }; + let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover()); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0)); + ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { Color32::from_rgb(30, 50, 32) }); + ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text }); + if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() { + let _ = app.run("view.grid_lines", json!({})); + } + let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0)); + ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text); + let gresp = ui.interact(gr, ui.id().with("grid_value"), Sense::click()); + value_menu(app, &gresp, "view.grid"); + ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label); + let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0)); + ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text); + let nresp = ui.interact(nr, ui.id().with("nudge_value"), Sense::click()); + value_menu(app, &nresp, "view.nudge"); + for rr in [gr, nr] { + icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim); + } +} + +fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) { + egui::Popup::menu(resp).show(|ui| { + for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] { + if ui.button(v).clicked() { + let _ = app.run(cmd, json!({"value": v})); + } + } + ui.separator(); + for (label, v) in [ + ("1 msec", json!({"seconds": 0.001})), + ("10 msec", json!({"seconds": 0.01})), + ("100 msec", json!({"seconds": 0.1})), + ("1 second", json!({"seconds": 1.0})), + ("1 frame", json!({"frames": 1})), + ("100 samples", json!({"samples": 100})), + ] { + if ui.button(label).clicked() { + let _ = app.run(cmd, json!({"value": v})); + } + } + }); +} + +fn transport(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let playing = app.is_playing(); + let recording = app.engine.transport.recording; + let looped = app.engine.session().edit.loop_playback; + group(ui, 176.0, 64.0, |ui, _| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + if icon_button(ui, vec2(38.0, 28.0), "loop", looped, "Loop Playback (Cmd+Shift+L)").clicked() { + let _ = app.run("options.loop_playback", json!({})); + } + let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); + ui.painter().rect( + r, + CornerRadius::same(3), + if resp.hovered() { t.button_hi } else { t.button }, + Stroke::new(1.0, t.button_border), + StrokeKind::Inside, + ); + icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent }); + if resp.on_hover_text("Stop (Space)").clicked() { + let _ = app.run("transport.stop", json!({})); + } + let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); + ui.painter().rect( + r, + CornerRadius::same(3), + if playing { + Color32::from_rgb(40, 90, 50) + } else if resp.hovered() { + t.button_hi + } else { + t.button + }, + Stroke::new(1.0, t.button_border), + StrokeKind::Inside, + ); + icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text }); + if resp.on_hover_text("Play (Space)").clicked() { + let _ = app.run("transport.play", json!({})); + } + let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); + ui.painter().rect( + r, + CornerRadius::same(3), + if recording { + Color32::from_rgb(110, 30, 30) + } else if resp.hovered() { + t.button_hi + } else { + t.button + }, + Stroke::new(1.0, t.button_border), + StrokeKind::Inside, + ); + icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec); + if resp.on_hover_text("Record (Cmd+Space)").clicked() { + let _ = app.run("transport.record", json!({})); + } + }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + for (icon, cmd, tip) in [ + ("rtz", "transport.rtz", "Return to Zero (Home)"), + ("rewind", "transport.rewind", "Rewind"), + ("ffwd", "transport.fast_forward", "Fast Forward"), + ("end", "transport.go_to_end", "Go to End (End)"), + ] { + if icon_button(ui, vec2(38.0, 22.0), icon, false, tip).clicked() { + let _ = app.run(cmd, json!({})); + } + } + }); + }); + }); +} + +fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { + let t = Tokens::DARK; + let (bpm, meter, countoff, click, bars) = { + let s = app.engine.session(); + let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate); + let m = s.tempo.meter_at_tick(tick); + (s.tempo.tempo_at_tick(tick), format!("{}/{}", m.numerator, m.denominator), s.edit.countoff, s.edit.click, s.edit.countoff_bars) + }; + let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover()); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0)); + ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { Color32::from_rgb(30, 50, 32) }); + ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text }); + if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() { + let _ = app.run("options.countoff", json!({})); + } + ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text); + ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 32.0), Align2::RIGHT_CENTER, "Meter", regular(11.0), t.counter_label); + ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text); + ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label); + icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text); + ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 48.0), Align2::RIGHT_CENTER, format!("{bpm:.4}"), mono(11.0), t.counter_text); + let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0)); + icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim }); + if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() { + let _ = app.run("options.click", json!({})); + } +} + +fn output_meter(app: &mut SoundApp, ui: &mut Ui) { + let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover()); + let m = app.main_meter; + let w = (r.width() - 6.0) / 2.0; + for c in 0..2 { + let rr = Rect::from_min_size(pos2(r.min.x + 2.0 + c as f32 * (w + 2.0), r.min.y), vec2(w, r.height())); + crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 0000000..7b25f2c --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,215 @@ +//! Small custom widgets in the studio style. + +use crate::icons; +use crate::theme::{Tokens, bold, regular}; +use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +/// A toolbar button with an icon; `on` draws the blue selected state. +pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response { + let t = Tokens::DARK; + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let fill = if on { + t.accent + } else if resp.hovered() { + t.button_hi + } else { + t.button + }; + ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); + let col = if on { Color32::WHITE } else { t.text }; + let s = r.height().min(r.width()) * 0.78; + icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col); + resp.on_hover_text(tip) +} + +/// Square text toggle used for S / M / I / record etc. +pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response { + let t = Tokens::DARK; + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let fill = if on { + on_color + } else if resp.hovered() { + t.button_hi + } else { + t.button + }; + ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); + let col = if on { t.text_dark } else { t.text }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col); + resp.on_hover_text(tip) +} + +/// Record-enable button (red dot). +pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { + let t = Tokens::DARK; + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let fill = if on { + t.rec + } else if resp.hovered() { + t.button_hi + } else { + t.button + }; + ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); + ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) }); + resp.on_hover_text(tip) +} + +/// A dropdown-looking selector box. Returns the response (caller opens a menu on click). +pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response { + let t = Tokens::DARK; + let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + ui.painter().rect( + r, + CornerRadius::same(2), + if resp.hovered() { t.button_hi } else { t.button }, + Stroke::new(1.0, t.button_border), + StrokeKind::Inside, + ); + let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); + icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); + let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0)); + let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5); + ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color); + resp +} + +/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. +pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response { + let t = Tokens::DARK; + let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + if resp.dragged() { + let d = resp.drag_delta(); + *value = (*value + (d.x - d.y) * 0.01).clamp(-1.0, 1.0); + } + if resp.double_clicked() { + *value = 0.0; + } + let c = r.center(); + let rad = size * 0.42; + ui.painter().circle(c, rad, Color32::from_rgb(26, 26, 28), Stroke::new(1.0, Color32::from_rgb(90, 90, 94))); + let n = 24; + let start = std::f32::consts::PI * 0.75; + let sweep = std::f32::consts::PI * 1.5; + let arc = |a0: f32, a1: f32| -> Vec { + (0..=n) + .map(|i| { + let a = a0 + (a1 - a0) * i as f32 / n as f32; + c + vec2(a.cos(), a.sin()) * (rad + 2.5) + }) + .collect() + }; + let mid = start + sweep * 0.5; + let at = start + sweep * (*value * 0.5 + 0.5); + let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) }; + ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text))); + let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85; + ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, Color32::from_rgb(230, 230, 230))); + resp.on_hover_text(tip) +} + +/// Format a pan value like a console: "<45", "0", "45>". +pub fn pan_text(v: f32) -> String { + let n = (v * 100.0).round() as i32; + match n { + 0 => "0".into(), + n if n < 0 => format!("<{}", -n), + n => format!("{n}>"), + } +} + +/// Vertical peak meter with green/yellow/red zones; values are linear peaks. +pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { + let t = Tokens::DARK; + ui.painter().rect_filled(r, 0.0, Color32::from_rgb(8, 8, 8)); + let pos = |v: f32| -> f32 { + let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }; + meter_pos(db) + }; + let h = r.height() - 4.0; + let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0)); + let lv = pos(level); + if lv > 0.0 { + let top = inner.max.y - h * lv; + // Draw in zones. + let zones = + [(0.0, meter_pos(-12.0), t.meter_green), (meter_pos(-12.0), meter_pos(-3.0), t.meter_yellow), (meter_pos(-3.0), 1.0, t.meter_red)]; + for (a, b, col) in zones { + let y0 = inner.max.y - h * a; + let y1 = (inner.max.y - h * b).max(top); + if y1 < y0 { + ui.painter().rect_filled(Rect::from_min_max(pos2(inner.min.x, y1), pos2(inner.max.x, y0)), 0.0, col); + } + } + } + let hv = pos(hold); + if hv > 0.01 { + let y = inner.max.y - h * hv; + ui.painter().line_segment( + [pos2(inner.min.x, y), pos2(inner.max.x, y)], + Stroke::new(1.0, if hv > meter_pos(-3.0) { t.meter_red } else { t.meter_yellow }), + ); + } + let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5)); + ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { Color32::from_rgb(60, 20, 20) }); +} + +/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters). +pub fn meter_pos(db: f32) -> f32 { + if !db.is_finite() || db <= -80.0 { + return 0.0; + } + if db >= 0.0 { + return 1.0; + } + // -80..-40: bottom 10 %, -40..-20: 20 %, -20..0: 70 %. + if db < -40.0 { + (db + 80.0) / 40.0 * 0.1 + } else if db < -20.0 { + 0.1 + (db + 40.0) / 20.0 * 0.2 + } else { + 0.3 + (db + 20.0) / 20.0 * 0.7 + } +} + +/// A fader: returns the new dB value when dragged. `db` in -144..12. +pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { + let t = Tokens::DARK; + let resp = ui.interact(r, id, Sense::click_and_drag()); + let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0)); + ui.painter().rect_filled(slot, 2.0, t.fader_track); + // Scale marks. + for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] { + let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark); + ui.painter().line_segment([pos2(r.min.x + 2.0, y), pos2(slot.min.x - 3.0, y)], Stroke::new(1.0, Color32::from_rgb(110, 110, 110))); + ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), Color32::from_rgb(150, 150, 150)); + } + let pos = soundcraft_model::fader_db_to_pos(db); + let y = slot.max.y - slot.height() * pos; + let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(r.width() * 0.62, 22.0)); + ui.painter().rect(cap, 2.0, Color32::from_rgb(70, 70, 72), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside); + ui.painter().line_segment( + [pos2(cap.min.x + 2.0, cap.center().y), pos2(cap.max.x - 2.0, cap.center().y)], + Stroke::new(2.0, Color32::from_rgb(230, 230, 230)), + ); + for dy in [-6.0, -3.0, 3.0, 6.0] { + ui.painter().line_segment( + [pos2(cap.min.x + 3.0, cap.center().y + dy), pos2(cap.max.x - 3.0, cap.center().y + dy)], + Stroke::new(1.0, Color32::from_rgb(40, 40, 40)), + ); + } + if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) { + return Some(0.0); + } + if resp.dragged() { + let fine = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); + let dy = resp.drag_delta().y / slot.height() * if fine { 0.1 } else { 1.0 }; + let np = (pos - dy).clamp(0.0, 1.0); + return Some(soundcraft_model::fader_pos_to_db(np).clamp(-144.0, 12.0)); + } + None +} + +pub fn db_text(db: f32) -> String { + if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") } +}