From 09f8d6a7cd2de4b424ba74e5495157fdff4fdffe Mon Sep 17 00:00:00 2001 From: Benjamin Klum Date: Sun, 3 Nov 2024 12:20:58 +0100 Subject: [PATCH] #1301 Add Stream Deck support WIP --- Cargo.lock | 275 +++++++++++-- Cargo.toml | 1 + api/src/persistence/source.rs | 60 +++ dialogs/src/mapping_panel.rs | 6 +- main/Cargo.toml | 8 +- main/lib/helgoboss-learn | 2 +- main/src/application/source_model.rs | 140 ++++++- main/src/application/unit_model.rs | 22 +- main/src/domain/backbone.rs | 111 ++++- main/src/domain/control_surface.rs | 139 ++++--- main/src/domain/feedback_collector.rs | 5 + main/src/domain/main_processor.rs | 42 +- main/src/domain/mapping.rs | 65 ++- main/src/domain/mod.rs | 7 + main/src/domain/stream_deck_device.rs | 92 +++++ main/src/domain/stream_deck_source.rs | 164 ++++++++ .../api/convert/from_data/source.rs | 7 + .../api/convert/to_data/source.rs | 9 + .../infrastructure/data/source_model_data.rs | 18 +- main/src/infrastructure/data/unit_data.rs | 13 +- main/src/infrastructure/ui/bindings.rs | 379 +++++++++--------- .../ui/companion_app_presenter.rs | 6 +- main/src/infrastructure/ui/header_panel.rs | 42 +- main/src/infrastructure/ui/mapping_panel.rs | 167 ++++---- main/src/infrastructure/ui/menus.rs | 181 +++++---- 25 files changed, 1498 insertions(+), 463 deletions(-) create mode 100644 main/src/domain/stream_deck_device.rs create mode 100644 main/src/domain/stream_deck_source.rs diff --git a/Cargo.lock b/Cargo.lock index f64329fcf..f98245a1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "ab_glyph" -version = "0.2.17" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a9283dace1c41c265496614998d5b9c4a97b3eb770e804f007c5144bf03f2b" +checksum = "ec3672c180e71eeaaac3a541fbbc5f5ad4def8b747c595ad30d674e43049f7b0" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -1069,6 +1069,21 @@ dependencies = [ "libloading 0.7.3", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap 0.11.0", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "3.2.25" @@ -1083,7 +1098,7 @@ dependencies = [ "once_cell", "strsim 0.10.0", "termcolor", - "textwrap", + "textwrap 0.16.0", ] [[package]] @@ -1115,7 +1130,7 @@ version = "3.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro-error", "proc-macro2", "quote", @@ -1128,7 +1143,7 @@ version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.48", @@ -1280,6 +1295,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "conv" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" +dependencies = [ + "custom_derive", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1537,6 +1561,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "custom_derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" + [[package]] name = "darling" version = "0.10.2" @@ -2671,8 +2701,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2881,6 +2913,15 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -2897,6 +2938,7 @@ dependencies = [ "derivative", "derive_more", "helgoboss-midi", + "image 0.25.2", "lazycell", "logos 0.13.0", "nom", @@ -3006,8 +3048,9 @@ dependencies = [ "helgobox-api", "helgobox-dialogs", "hex", + "hidapi", "hostname", - "image 0.24.8", + "image 0.25.2", "include_dir", "indexmap 2.1.0", "itertools 0.12.0", @@ -3056,6 +3099,7 @@ dependencies = [ "slug", "smallvec", "static_assertions", + "streamdeck", "strum", "swell-ui", "sys-info", @@ -3102,7 +3146,7 @@ dependencies = [ "derive_more", "enum-map", "enumset", - "heck", + "heck 0.4.1", "helgoboss-license-api", "helgobox-macros", "mlua", @@ -3178,6 +3222,19 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hidapi" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b876ecf37e86b359573c16c8366bc3eba52b689884a0fc42ba3f67203d2a8b" +dependencies = [ + "cc 1.0.83", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.48.0", +] + [[package]] name = "home" version = "0.5.9" @@ -3360,12 +3417,8 @@ dependencies = [ "bytemuck", "byteorder", "color_quant", - "exr", - "gif 0.12.0", - "jpeg-decoder", "num-traits", "png", - "qoi", "tiff", ] @@ -3402,6 +3455,25 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2a0d7770f428b4615960cc8602775d1f04c75d41b0ccdef862e889ebaae9bbf" +dependencies = [ + "ab_glyph", + "approx", + "conv", + "getrandom", + "image 0.25.2", + "itertools 0.12.0", + "nalgebra", + "num", + "rand", + "rand_distr", + "rayon", +] + [[package]] name = "imagesize" version = "0.12.0" @@ -3606,9 +3678,6 @@ name = "jpeg-decoder" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" -dependencies = [ - "rayon", -] [[package]] name = "js-sys" @@ -3971,6 +4040,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +[[package]] +name = "matrixmultiply" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4144,6 +4223,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -4377,6 +4471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4627,11 +4722,11 @@ checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" [[package]] name = "owned_ttf_parser" -version = "0.15.2" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e6affeb1632d6ff6a23d2cd40ffed138e82f1532571a26f527c8a284bb2fbb" +checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" dependencies = [ - "ttf-parser 0.15.2", + "ttf-parser 0.25.0", ] [[package]] @@ -5163,11 +5258,11 @@ dependencies = [ [[package]] name = "qrcode" -version = "0.13.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" dependencies = [ - "image 0.24.8", + "image 0.25.2", ] [[package]] @@ -5239,6 +5334,16 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + [[package]] name = "rav1e" version = "0.6.6" @@ -5317,6 +5422,12 @@ dependencies = [ "cty", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.8.1" @@ -5965,6 +6076,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "safe_arch" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3460605018fdc9612bce72735cba0d27efbcd9904780d44c7e3a9948f96148a" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6249,6 +6369,19 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -6282,6 +6415,17 @@ dependencies = [ "log", ] +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time 0.3.14", +] + [[package]] name = "siphasher" version = "0.3.7" @@ -6437,6 +6581,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" +[[package]] +name = "streamdeck" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67325ca3ab85003cac6eb10395aa8c7506b4c273ff48513604e658b85f77ea72" +dependencies = [ + "ab_glyph", + "hidapi", + "humantime", + "image 0.25.2", + "imageproc", + "log", + "simplelog", + "structopt", + "thiserror", +] + [[package]] name = "strict-num" version = "0.1.1" @@ -6446,6 +6607,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.9.3" @@ -6458,6 +6625,30 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "structopt" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" +dependencies = [ + "clap 2.34.0", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +dependencies = [ + "heck 0.3.3", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "strum" version = "0.25.0" @@ -6473,7 +6664,7 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "rustversion", @@ -6620,7 +6811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a45a1c4c9015217e12347f2a411b57ce2c4fc543913b14b6fe40483328e709" dependencies = [ "cfg-expr", - "heck", + "heck 0.4.1", "pkg-config", "toml 0.5.8", "version-compare", @@ -6660,6 +6851,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "textwrap" version = "0.16.0" @@ -6735,8 +6935,15 @@ dependencies = [ "libc", "num_threads", "serde", + "time-macros", ] +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -7117,15 +7324,15 @@ checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" [[package]] name = "ttf-parser" -version = "0.15.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b3e06c9b9d80ed6b745c7159c40b311ad2916abb34a49e9be2653b90db0d8dd" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" [[package]] name = "ttf-parser" -version = "0.20.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +checksum = "5902c5d130972a0000f60860bfbf46f7ca3db5391eddfedd1b8728bd9dc96c0e" [[package]] name = "tts" @@ -7475,6 +7682,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.1.1" @@ -7774,6 +7987,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "0.7.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b828f995bf1e9622031f8009f8481a85406ce1f4d4588ff746d872043e855690" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "wildmatch" version = "2.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9b8546ccd..ab10103bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ open = "5.0.1" url = "2.5.2" atomic = "0.6.0" static_assertions = "1.1.0" +image = { version = "0.25.2", default-features = false } [profile.release] # This is important for having line numbers in bug reports. diff --git a/api/src/persistence/source.rs b/api/src/persistence/source.rs index f09758e1f..691ce4e6e 100644 --- a/api/src/persistence/source.rs +++ b/api/src/persistence/source.rs @@ -41,6 +41,8 @@ pub enum Source { Osc(OscSource), // Keyboard Key(KeySource), + // StreamDeck + StreamDeck(StreamDeckSource), // Virtual Virtual(VirtualSource), } @@ -308,6 +310,64 @@ pub struct KeySource { pub keystroke: Option, } +#[derive(Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct StreamDeckSource { + pub button_index: u32, + pub button_design: StreamDeckButtonDesign, +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonDesign { + pub background: StreamDeckButtonBackground, + pub foreground: StreamDeckButtonForeground, +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum StreamDeckButtonForeground { + Solid(StreamDeckButtonSolidForeground), + Image(StreamDeckButtonImageForeground), + Bar(StreamDeckButtonBarForeground), + Arc(StreamDeckButtonArcForeground), +} + +impl Default for StreamDeckButtonForeground { + fn default() -> Self { + Self::Solid(StreamDeckButtonSolidForeground::default()) + } +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum StreamDeckButtonBackground { + Solid(StreamDeckButtonSolidBackground), + Image(StreamDeckButtonImageBackground), +} + +impl Default for StreamDeckButtonBackground { + fn default() -> Self { + Self::Solid(StreamDeckButtonSolidBackground::default()) + } +} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonImageForeground {} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonSolidForeground {} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonBarForeground {} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonArcForeground {} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonImageBackground {} + +#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize)] +pub struct StreamDeckButtonSolidBackground {} + #[derive(Copy, Clone, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct Keystroke { pub modifiers: u8, diff --git a/dialogs/src/mapping_panel.rs b/dialogs/src/mapping_panel.rs index d1380a712..8b610c605 100644 --- a/dialogs/src/mapping_panel.rs +++ b/dialogs/src/mapping_panel.rs @@ -90,17 +90,13 @@ pub fn create(context: ScopedContext, ids: &mut IdGenerator) -> Dialog { ) + NOT_WS_GROUP, dropdown( ids.named_id("ID_SOURCE_CHANNEL_COMBO_BOX"), - context.rect(48, 136, 120, 30), + context.rect(48, 136, 120, 15), ) + WS_VSCROLL + WS_TABSTOP, edittext( ids.named_id("ID_SOURCE_LINE_3_EDIT_CONTROL"), context.rect(48, 135, 120, 14), ) + ES_AUTOHSCROLL, - dropdown( - ids.named_id("ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX"), - context.rect(48, 136, 120, 15), - ) + WS_TABSTOP, ltext( "Note/CC number", ids.named_id("ID_SOURCE_NOTE_OR_CC_NUMBER_LABEL_TEXT"), diff --git a/main/Cargo.toml b/main/Cargo.toml index 6862e1f48..c60db9ca8 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -98,9 +98,9 @@ tower-http = { version = "0.4.2", features = ["cors"] } tonic.workspace = true prost.workspace = true # For generating projection QR code -qrcode = { version = "0.13.0" } +qrcode = { version = "0.14.1" } # For rendering projection QR code to PNG -image = "0.24.8" +image = { workspace = true, features = ["png"] } # For generating self-signed certificate for projection web server rcgen = "0.12.0" # For showing different ways of connecting to this computer (projection feature) @@ -209,6 +209,10 @@ serde_plain.workspace = true atomic.workspace = true # For making sure that sharing global audio state uses atomics static_assertions.workspace = true +# For Stream Deck support +streamdeck = "0.9.0" +# For Stream Deck support +hidapi = "2.4" [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] # For speech source diff --git a/main/lib/helgoboss-learn b/main/lib/helgoboss-learn index ce0bff7dd..c9ed6d39f 160000 --- a/main/lib/helgoboss-learn +++ b/main/lib/helgoboss-learn @@ -1 +1 @@ -Subproject commit ce0bff7ddebf4f94a2dc0bc22a8c049bd1317d8c +Subproject commit c9ed6d39f8aab5a77383c6c7a29166b646cd9013 diff --git a/main/src/application/source_model.rs b/main/src/application/source_model.rs index cc2ee77be..35b4dd173 100644 --- a/main/src/application/source_model.rs +++ b/main/src/application/source_model.rs @@ -5,7 +5,7 @@ use crate::base::CloneAsDefault; use crate::domain::{ Backbone, CompartmentKind, CompartmentParamIndex, CompoundMappingSource, EelMidiSourceScript, ExtendedSourceCharacter, FlexibleMidiSourceScript, KeySource, Keystroke, LuaMidiSourceScript, - MidiSource, RealearnParameterSource, ReaperSource, SpeechSource, TimerSource, + MidiSource, RealearnParameterSource, ReaperSource, SpeechSource, StreamDeckSource, TimerSource, VirtualControlElement, VirtualControlElementId, VirtualSource, }; use derive_more::Display; @@ -16,7 +16,10 @@ use helgoboss_learn::{ DEFAULT_OSC_ARG_VALUE_RANGE, }; use helgoboss_midi::{Channel, U14, U7}; -use helgobox_api::persistence::{MidiScriptKind, VirtualControlElementCharacter}; +use helgobox_api::persistence::{ + MidiScriptKind, StreamDeckButtonBackground, StreamDeckButtonDesign, StreamDeckButtonForeground, + VirtualControlElementCharacter, +}; use num_enum::{IntoPrimitive, TryFromPrimitive}; use serde::{Deserialize, Serialize}; use serde_repr::*; @@ -54,6 +57,9 @@ pub enum SourceCommand { SetTimerMillis(u64), SetParameterIndex(CompartmentParamIndex), SetKeystroke(Option), + SetButtonIndex(u32), + SetButtonBackgroundType(StreamDeckButtonBackgroundType), + SetButtonForegroundType(StreamDeckButtonForegroundType), SetControlElementCharacter(VirtualControlElementCharacter), SetControlElementId(VirtualControlElementId), } @@ -87,6 +93,9 @@ pub enum SourceProp { TimerMillis, ParameterIndex, Keystroke, + ButtonIndex, + ButtonBackgroundType, + ButtonForegroundType, } impl GetProcessingRelevance for SourceProp { @@ -213,6 +222,18 @@ impl<'a> Change<'a> for SourceModel { self.keystroke = v; One(P::Keystroke) } + C::SetButtonIndex(v) => { + self.button_index = v; + One(P::ButtonIndex) + } + C::SetButtonBackgroundType(v) => { + self.button_background_type = v; + One(P::ButtonBackgroundType) + } + C::SetButtonForegroundType(v) => { + self.button_foreground_type = v; + One(P::ButtonForegroundType) + } }; Some(affected) } @@ -250,6 +271,10 @@ pub struct SourceModel { parameter_index: CompartmentParamIndex, // Key keystroke: Option, + // Stream Deck + button_index: u32, + button_background_type: StreamDeckButtonBackgroundType, + button_foreground_type: StreamDeckButtonForegroundType, // Virtual control_element_character: VirtualControlElementCharacter, control_element_id: VirtualControlElementId, @@ -291,6 +316,9 @@ impl SourceModel { timer_millis: Default::default(), parameter_index: Default::default(), keystroke: None, + button_index: 0, + button_background_type: Default::default(), + button_foreground_type: Default::default(), } } @@ -382,6 +410,18 @@ impl SourceModel { self.keystroke } + pub fn button_index(&self) -> u32 { + self.button_index + } + + pub fn button_background_type(&self) -> StreamDeckButtonBackgroundType { + self.button_background_type + } + + pub fn button_foreground_type(&self) -> StreamDeckButtonForegroundType { + self.button_foreground_type + } + pub fn reaper_source_type(&self) -> ReaperSourceType { self.reaper_source_type } @@ -408,7 +448,7 @@ impl SourceModel { Midi => self.midi_source_type.supports_control(), Osc => self.osc_arg_type_tag.supports_control(), Reaper => self.reaper_source_type.supports_control(), - Virtual | Keyboard => true, + Virtual | Keyboard | StreamDeck => true, // Main use case: Group interaction (follow-only). Never => true, } @@ -420,7 +460,7 @@ impl SourceModel { Midi => self.midi_source_type.supports_feedback(), Osc => self.osc_arg_type_tag.supports_feedback(), Reaper => self.reaper_source_type.supports_feedback(), - Virtual => true, + StreamDeck | Virtual => true, Keyboard | Never => false, } } @@ -514,6 +554,10 @@ impl SourceModel { MidiDeviceChanges | RealearnInstanceStart | Timer(_) | Speech(_) => {} } } + StreamDeck(s) => { + self.category = SourceCategory::StreamDeck; + self.button_index = s.button_index; + } Never => { self.category = SourceCategory::Never; } @@ -563,7 +607,9 @@ impl SourceModel { DetailedSourceCharacter::RangeControl, DetailedSourceCharacter::Relative, ], - CompoundMappingSource::Key(_) => vec![DetailedSourceCharacter::MomentaryOnOffButton], + CompoundMappingSource::Key(_) | CompoundMappingSource::StreamDeck(_) => { + vec![DetailedSourceCharacter::MomentaryOnOffButton] + } } } @@ -683,12 +729,44 @@ impl SourceModel { }; CompoundMappingSource::Reaper(reaper_source) } - Never => CompoundMappingSource::Never, Keyboard => CompoundMappingSource::Key(self.create_key_source()?), + StreamDeck => CompoundMappingSource::StreamDeck(self.create_stream_deck_source()), + Never => CompoundMappingSource::Never, }; Some(source) } + pub fn create_stream_deck_source(&self) -> StreamDeckSource { + StreamDeckSource::new(self.button_index, self.create_stream_deck_button_design()) + } + + pub fn create_stream_deck_button_design(&self) -> StreamDeckButtonDesign { + StreamDeckButtonDesign { + background: match self.button_background_type { + StreamDeckButtonBackgroundType::Solid => { + StreamDeckButtonBackground::Solid(Default::default()) + } + StreamDeckButtonBackgroundType::Image => { + StreamDeckButtonBackground::Image(Default::default()) + } + }, + foreground: match self.button_foreground_type { + StreamDeckButtonForegroundType::Solid => { + StreamDeckButtonForeground::Solid(Default::default()) + } + StreamDeckButtonForegroundType::Image => { + StreamDeckButtonForeground::Image(Default::default()) + } + StreamDeckButtonForegroundType::Bar => { + StreamDeckButtonForeground::Bar(Default::default()) + } + StreamDeckButtonForegroundType::Arc => { + StreamDeckButtonForeground::Arc(Default::default()) + } + }, + } + } + pub fn create_key_source(&self) -> Option { Some(KeySource::new(self.keystroke?)) } @@ -951,6 +1029,10 @@ impl Display for SourceModel { .unwrap_or_else(|| Cow::Borrowed(KEY_UNDEFINED_LABEL)); vec![text] } + StreamDeck => { + let text = self.create_stream_deck_source().to_string(); + vec![Cow::Owned(text)] + } }; let non_empty_lines: Vec<_> = lines.into_iter().filter(|l| !l.is_empty()).collect(); write!(f, "{}", non_empty_lines.join("\n")) @@ -991,6 +1073,9 @@ pub enum SourceCategory { #[serde(rename = "reaper")] #[display(fmt = "REAPER")] Reaper, + #[serde(rename = "stream-deck")] + #[display(fmt = "Stream Deck")] + StreamDeck, #[serde(rename = "virtual")] #[display(fmt = "Virtual")] Virtual, @@ -1014,6 +1099,7 @@ impl SourceCategory { Osc => true, Reaper => true, Keyboard => true, + StreamDeck => true, Virtual => false, }, CompartmentKind::Main => true, @@ -1223,6 +1309,48 @@ impl ReaperSourceType { } } +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Default, EnumIter, TryFromPrimitive, IntoPrimitive, Display, +)] +#[repr(usize)] +pub enum StreamDeckButtonBackgroundType { + #[default] + Solid, + Image, +} + +impl From<&StreamDeckButtonBackground> for StreamDeckButtonBackgroundType { + fn from(value: &StreamDeckButtonBackground) -> Self { + match value { + StreamDeckButtonBackground::Solid(_) => Self::Solid, + StreamDeckButtonBackground::Image(_) => Self::Image, + } + } +} + +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Default, EnumIter, TryFromPrimitive, IntoPrimitive, Display, +)] +#[repr(usize)] +pub enum StreamDeckButtonForegroundType { + #[default] + Solid, + Image, + Bar, + Arc, +} + +impl From<&StreamDeckButtonForeground> for StreamDeckButtonForegroundType { + fn from(value: &StreamDeckButtonForeground) -> Self { + match value { + StreamDeckButtonForeground::Solid(_) => Self::Solid, + StreamDeckButtonForeground::Image(_) => Self::Image, + StreamDeckButtonForeground::Arc(_) => Self::Arc, + StreamDeckButtonForeground::Bar(_) => Self::Bar, + } + } +} + pub fn parse_osc_feedback_args(text: &str) -> Vec { text.split_whitespace().map(|s| s.to_owned()).collect() } diff --git a/main/src/application/unit_model.rs b/main/src/application/unit_model.rs index 4bb159e45..865600e57 100644 --- a/main/src/application/unit_model.rs +++ b/main/src/application/unit_model.rs @@ -18,8 +18,8 @@ use crate::domain::{ MessageCaptureEvent, MidiControlInput, NormalMainTask, OscFeedbackTask, ParamSetting, PluginParams, ProcessorContext, ProjectionFeedbackValue, QualifiedMappingId, RealearnControlSurfaceMainTask, RealearnTarget, ReaperTarget, ReaperTargetType, SharedInstance, - SharedUnit, SourceFeedbackEvent, StayActiveWhenProjectInBackground, Tag, TargetControlEvent, - TargetTouchEvent, TargetValueChangedEvent, Unit, UnitContainer, UnitId, + SharedUnit, SourceFeedbackEvent, StayActiveWhenProjectInBackground, StreamDeckDeviceId, Tag, + TargetControlEvent, TargetTouchEvent, TargetValueChangedEvent, Unit, UnitContainer, UnitId, VirtualControlElementId, VirtualFx, VirtualSource, VirtualSourceValue, LUA_FEEDBACK_SCRIPT_RUNTIME_NAME, LUA_MIDI_SCRIPT_SOURCE_RUNTIME_NAME, }; @@ -103,6 +103,7 @@ pub struct UnitModel { pub reset_feedback_when_releasing_source: Prop, pub control_input: Prop, wants_keyboard_input: bool, + stream_deck_device_id: Option, pub feedback_output: Prop>, pub auto_load_mode: Prop, pub auto_load_fallback_compartment: Option, @@ -278,6 +279,7 @@ impl UnitModel { ), control_input: prop(initial_input), wants_keyboard_input: session_defaults::WANTS_KEYBOARD_INPUT, + stream_deck_device_id: None, feedback_output: prop(initial_output), auto_load_mode: prop(session_defaults::MAIN_PRESET_AUTO_LOAD_MODE), auto_load_fallback_compartment: None, @@ -469,6 +471,9 @@ impl UnitModel { _ => false, }, InputDescriptor::Keyboard => self.wants_keyboard_input, + InputDescriptor::StreamDeck { device_id } => { + self.stream_deck_device_id == Some(*device_id) + } } } @@ -766,6 +771,10 @@ impl UnitModel { res } + pub fn stream_deck_device_id(&self) -> Option { + self.stream_deck_device_id + } + pub fn wants_keyboard_input(&self) -> bool { self.wants_keyboard_input } @@ -1139,6 +1148,10 @@ impl UnitModel { self.wants_keyboard_input = value; Some(One(P::WantsKeyboardInput)) } + C::SetStreamDeckDevice(value) => { + self.stream_deck_device_id = value; + Some(One(P::StreamDeckDeviceId)) + } C::ChangeCompartment(compartment, cmd) => self .change_compartment_internal(compartment, cmd)? .map(|affected| One(P::InCompartment(compartment, affected))), @@ -1272,7 +1285,7 @@ impl UnitModel { One(UnitName) => { session.ui().handle_unit_name_changed(); } - One(WantsKeyboardInput) => { + One(WantsKeyboardInput | StreamDeckDeviceId) => { session.sync_settings(); } One(InCompartment(compartment, One(Notes))) => { @@ -2601,6 +2614,7 @@ impl UnitModel { let settings = BasicSettings { control_input: self.control_input(), wants_keyboard_input: self.wants_keyboard_input, + streamdeck_device_id: self.stream_deck_device_id, feedback_output: self.feedback_output(), real_input_logging_enabled: self.real_input_logging_enabled.get(), real_output_logging_enabled: self.real_output_logging_enabled.get(), @@ -3017,6 +3031,7 @@ pub enum SessionCommand { SetInstanceTrack(TrackDescriptor), SetInstanceFx(FxDescriptor), SetWantsKeyboardInput(bool), + SetStreamDeckDevice(Option), ChangeCompartment(CompartmentKind, CompartmentCommand), AdjustMappingModeIfNecessary(QualifiedMappingId), } @@ -3026,6 +3041,7 @@ pub enum SessionProp { InstanceTrack, InstanceFx, WantsKeyboardInput, + StreamDeckDeviceId, InCompartment(CompartmentKind, Affected), } diff --git a/main/src/domain/backbone.rs b/main/src/domain/backbone.rs index eff23a867..ff3e66921 100644 --- a/main/src/domain/backbone.rs +++ b/main/src/domain/backbone.rs @@ -5,7 +5,8 @@ use base::{ use crate::domain::{ AdditionalFeedbackEvent, ControlInput, DeviceControlInput, DeviceFeedbackOutput, FeedbackOutput, InstanceId, RealearnSourceState, RealearnTargetState, ReaperTarget, - ReaperTargetType, SafeLua, SharedInstance, UnitId, WeakInstance, + ReaperTargetType, SafeLua, SharedInstance, StreamDeckDeviceId, StreamDeckDeviceManager, + StreamDeckSourceFeedbackValue, UnitId, WeakInstance, }; #[allow(unused)] use anyhow::{anyhow, Context}; @@ -13,7 +14,11 @@ use pot::{PotFavorites, PotFilterExcludes}; use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet}; use fragile::Fragile; -use helgobox_api::persistence::TargetTouchCause; +use helgoboss_learn::{RgbColor, UnitValue}; +use helgobox_api::persistence::{ + StreamDeckButtonBackground, StreamDeckButtonForeground, TargetTouchCause, +}; +use image::Pixel; use once_cell::sync::Lazy; use reaper_high::Fx; use std::cell::{Cell, Ref, RefCell, RefMut}; @@ -21,6 +26,7 @@ use std::hash::Hash; use std::rc::Rc; use std::sync::RwLock; use std::time::{Duration, Instant}; +use streamdeck::StreamDeck; use strum::EnumCount; make_available_globally_in_main_thread_on_demand!(Backbone); @@ -45,12 +51,14 @@ pub struct Backbone { /// We hold pointers to all ReaLearn instances in order to let instance B /// borrow a clip matrix which is owned by instance A. This is great because it allows us to /// control the same clip matrix from different controllers. - // TODO-high-playtime-refactoring Since the introduction of units, foreign matrixes are not used in practice. Let's + // TODO-high-playtime-refactoring Since the introduction of units, foreign matrices are not used in practice. Let's // keep this for a while and remove. instances: RefCell>, was_processing_keyboard_input: Cell, global_pot_filter_exclude_list: RefCell, recently_focused_fx_container: Rc>, + stream_deck_device_manager: RefCell, + stream_decks: RefCell>, } #[derive(Debug, Default)] @@ -165,7 +173,104 @@ impl Backbone { was_processing_keyboard_input: Default::default(), global_pot_filter_exclude_list: Default::default(), recently_focused_fx_container: Default::default(), + stream_deck_device_manager: Default::default(), + stream_decks: Default::default(), + } + } + + pub fn detect_stream_deck_device_changes(&self) { + let devices_in_use = self.stream_deck_device_manager.borrow().devices_in_use(); + let actually_connected_devices: NonCryptoHashSet<_> = + self.stream_decks.borrow().keys().copied().collect(); + if devices_in_use == actually_connected_devices { + return; + } + println!("Try to connect"); + self.connect_or_disconnect_stream_deck_devices(&devices_in_use); + } + + pub fn register_stream_deck_usage(&self, unit_id: UnitId, device: Option) { + // Change device usage + let mut manager = self.stream_deck_device_manager.borrow_mut(); + manager.register_device_usage(unit_id, device); + let devices_in_use = manager.devices_in_use(); + // Update connections + self.connect_or_disconnect_stream_deck_devices(&devices_in_use); + } + + fn connect_or_disconnect_stream_deck_devices( + &self, + devices_in_use: &NonCryptoHashSet, + ) { + let mut decks = self.stream_decks.borrow_mut(); + decks.retain(|id, _| devices_in_use.contains(id)); + for dev_id in devices_in_use { + if decks.contains_key(&dev_id) { + continue; + } + if let Ok(con) = dev_id.connect() { + decks.insert(*dev_id, con); + } + } + } + + pub fn stream_decks_mut(&self) -> RefMut> { + self.stream_decks.borrow_mut() + } + + pub fn send_stream_deck_feedback( + &self, + dev_id: StreamDeckDeviceId, + value: StreamDeckSourceFeedbackValue, + ) -> anyhow::Result<()> { + use image::{DynamicImage, ImageBuffer, Rgba}; + let mut stream_decks = self.stream_decks.borrow_mut(); + let sd = stream_decks + .get_mut(&dev_id) + .context("stream deck not connected")?; + const DEFAULT_BG_COLOR: RgbColor = RgbColor::BLACK; + const DEFAULT_FG_COLOR: RgbColor = RgbColor::WHITE; + let width = 72; + let height = 72; + // Paint background + let bg_color = value.background_color.unwrap_or(DEFAULT_BG_COLOR); + let mut img: ImageBuffer, _> = match value.button_design.background { + StreamDeckButtonBackground::Solid(_) => { + ImageBuffer::from_pixel(width, height, bg_color.into()) + } + StreamDeckButtonBackground::Image(_) => ImageBuffer::default(), + }; + // Paint foreground + if value.numeric_value.is_some() || value.text_value.is_some() { + let fg_color = value.foreground_color.unwrap_or(DEFAULT_FG_COLOR); + match value.button_design.foreground { + StreamDeckButtonForeground::Solid(_) => { + let opacity = value.numeric_value.unwrap_or(UnitValue::MAX); + let mut rgba: Rgba = fg_color.into(); + rgba[3] = (opacity.get() * 255.0).round() as u8; + for pixel in img.pixels_mut() { + pixel.blend(&rgba); + } + } + StreamDeckButtonForeground::Image(_) => {} + StreamDeckButtonForeground::Bar(_) => { + let percentage = value.numeric_value.map(|v| v.get()).unwrap_or(0.0); + let rect_height = (height as f64 * percentage) as u32; + // Fill the background + // TODO-high CONTINUE Only change foreground pixels. Background already painted. + for (x, y, pixel) in img.enumerate_pixels_mut() { + *pixel = if y >= height - rect_height { + fg_color.into() + } else { + bg_color.into() + }; + } + } + StreamDeckButtonForeground::Arc(_) => {} + } } + sd.set_button_image(value.button_index as _, DynamicImage::ImageRgba8(img))?; + Ok(()) } pub fn duration_since_time_of_start(&self) -> Duration { diff --git a/main/src/domain/control_surface.rs b/main/src/domain/control_surface.rs index 413a0a5ec..dd1d3905a 100644 --- a/main/src/domain/control_surface.rs +++ b/main/src/domain/control_surface.rs @@ -4,8 +4,8 @@ use crate::domain::{ MainProcessor, MidiDeviceChangeDetector, MidiDeviceChangePayload, MonitoringFxChainChangeDetector, OscDeviceId, OscInputDevice, OscScanResult, QualifiedInstanceEvent, ReaperConfigChangeDetector, ReaperMessage, ReaperTarget, - SharedInstance, SharedMainProcessors, TargetTouchEvent, TouchedTrackParameterType, UnitEvent, - UnitId, WeakInstance, + SharedInstance, SharedMainProcessors, StreamDeckDeviceId, StreamDeckMessage, TargetTouchEvent, + TouchedTrackParameterType, UnitEvent, UnitId, WeakInstance, }; use base::{metrics_util, Global, NamedChannelSender, SenderToNormalThread}; use crossbeam_channel::Receiver; @@ -28,6 +28,7 @@ use reaper_medium::{ use rxrust::prelude::*; use std::fmt::Debug; use std::mem; +use streamdeck::{Error, StreamDeck}; use tracing::debug; type OscCaptureSender = async_channel::Sender; @@ -69,6 +70,7 @@ pub struct RealearnControlSurfaceMiddleware { last_undesired_allocation_count: u32, event_handler: Box, osc_buffer: Vec, + stream_deck_button_states: NonCryptoHashMap>, } #[cfg(feature = "playtime")] @@ -248,6 +250,7 @@ impl RealearnControlSurfaceMiddleware { last_undesired_allocation_count: 0, event_handler, osc_buffer: Default::default(), + stream_deck_button_states: Default::default(), } } @@ -295,6 +298,7 @@ impl RealearnControlSurfaceMiddleware { self.detect_reaper_config_changes(); self.emit_focus_switch_between_main_and_fx_as_feedback_event(); self.emit_instance_events(); + self.emit_stream_deck_events(timestamp); self.emit_beats_as_feedback_events(); self.detect_device_changes(timestamp); self.process_incoming_osc_messages(timestamp); @@ -654,6 +658,48 @@ impl RealearnControlSurfaceMiddleware { } } + fn emit_stream_deck_events(&mut self, timestamp: ControlEventTimestamp) -> Option<()> { + let backbone = Backbone::get(); + let mut decks = backbone.stream_decks_mut(); + decks.retain(|id, deck| { + match self.emit_stream_deck_events_for_deck(*id, deck, timestamp) { + Ok(_) => true, + Err(streamdeck::Error::NoData) => true, + Err(e) => { + tracing::warn!(msg = "Error polling for stream deck events", %e); + false + } + } + }); + Some(()) + } + + fn emit_stream_deck_events_for_deck( + &mut self, + id: StreamDeckDeviceId, + sd: &mut StreamDeck, + timestamp: ControlEventTimestamp, + ) -> Result<(), streamdeck::Error> { + let old_button_states = self.stream_deck_button_states.entry(id).or_default(); + let new_button_states = sd.read_buttons(None)?; + for (i, new_is_on) in new_button_states.iter().enumerate() { + let old_is_on = old_button_states.get(i).copied().unwrap_or(0); + if *new_is_on == old_is_on { + continue; + } + let msg = StreamDeckMessage::new(i as u32, *new_is_on > 0); + for p in &mut *self.main_processors.borrow_mut() { + if !p.wants_stream_deck_input_from(id) { + continue; + } + let event = ControlEvent::new(msg, timestamp); + p.process_incoming_stream_deck_msg(event); + } + } + *old_button_states = new_button_states; + Ok(()) + } + fn emit_beats_as_feedback_events(&mut self) { for project in Reaper::get().projects() { let reference_pos = if project.is_playing() { @@ -675,51 +721,50 @@ impl RealearnControlSurfaceMiddleware { fn detect_device_changes(&mut self, timestamp: ControlEventTimestamp) { // Check roughly every 2 seconds - if self.counter % (30 * 2) == 0 { - let midi_in_diff = self - .device_change_detector - .poll_for_midi_input_device_changes(); - let midi_out_diff = self - .device_change_detector - .poll_for_midi_output_device_changes(); - // Resetting MIDI devices is necessary especially on Windows. - reset_midi_devices( - midi_in_diff.added_devices.iter().copied(), - midi_out_diff.added_devices.iter().copied(), - ); - // Handle events - if midi_in_diff.devices_changed() || midi_in_diff.device_config_changed { - self.event_handler - .midi_input_devices_changed(&midi_in_diff, midi_in_diff.device_config_changed); - } - if midi_out_diff.devices_changed() || midi_out_diff.device_config_changed { - self.event_handler.midi_output_devices_changed( - &midi_out_diff, - midi_out_diff.device_config_changed, - ); - } - // Emit as REAPER source messages - let mut msgs = Vec::with_capacity(2); - if !midi_in_diff.added_devices.is_empty() || !midi_out_diff.added_devices.is_empty() { - let payload = MidiDeviceChangePayload { - input_devices: midi_in_diff.added_devices, - output_devices: midi_out_diff.added_devices, - }; - msgs.push(ReaperMessage::MidiDevicesConnected(payload)); - } - if !midi_in_diff.removed_devices.is_empty() || !midi_out_diff.removed_devices.is_empty() - { - let payload = MidiDeviceChangePayload { - input_devices: midi_in_diff.removed_devices, - output_devices: midi_out_diff.removed_devices, - }; - msgs.push(ReaperMessage::MidiDevicesDisconnected(payload)); - } - for p in &mut *self.main_processors.borrow_mut() { - for msg in &msgs { - let evt = ControlEvent::new(msg, timestamp); - p.process_reaper_message(evt); - } + if self.counter % (30 * 2) != 0 { + return; + } + Backbone::get().detect_stream_deck_device_changes(); + let midi_in_diff = self + .device_change_detector + .poll_for_midi_input_device_changes(); + let midi_out_diff = self + .device_change_detector + .poll_for_midi_output_device_changes(); + // Resetting MIDI devices is necessary especially on Windows. + reset_midi_devices( + midi_in_diff.added_devices.iter().copied(), + midi_out_diff.added_devices.iter().copied(), + ); + // Handle events + if midi_in_diff.devices_changed() || midi_in_diff.device_config_changed { + self.event_handler + .midi_input_devices_changed(&midi_in_diff, midi_in_diff.device_config_changed); + } + if midi_out_diff.devices_changed() || midi_out_diff.device_config_changed { + self.event_handler + .midi_output_devices_changed(&midi_out_diff, midi_out_diff.device_config_changed); + } + // Emit as REAPER source messages + let mut msgs = Vec::with_capacity(2); + if !midi_in_diff.added_devices.is_empty() || !midi_out_diff.added_devices.is_empty() { + let payload = MidiDeviceChangePayload { + input_devices: midi_in_diff.added_devices, + output_devices: midi_out_diff.added_devices, + }; + msgs.push(ReaperMessage::MidiDevicesConnected(payload)); + } + if !midi_in_diff.removed_devices.is_empty() || !midi_out_diff.removed_devices.is_empty() { + let payload = MidiDeviceChangePayload { + input_devices: midi_in_diff.removed_devices, + output_devices: midi_out_diff.removed_devices, + }; + msgs.push(ReaperMessage::MidiDevicesDisconnected(payload)); + } + for p in &mut *self.main_processors.borrow_mut() { + for msg in &msgs { + let evt = ControlEvent::new(msg, timestamp); + p.process_reaper_message(evt); } } } diff --git a/main/src/domain/feedback_collector.rs b/main/src/domain/feedback_collector.rs index 19a45a6ce..b012e341d 100644 --- a/main/src/domain/feedback_collector.rs +++ b/main/src/domain/feedback_collector.rs @@ -73,6 +73,11 @@ impl<'a> FeedbackCollector<'a> { preliminary_feedback_value.projection, Some(FinalSourceFeedbackValue::Reaper(v)), ), + // Is final StreamDeck source value already. + PreliminarySourceFeedbackValue::StreamDeck(v) => FinalRealFeedbackValue::new( + preliminary_feedback_value.projection, + Some(FinalSourceFeedbackValue::StreamDeck(v)), + ), }, } } diff --git a/main/src/domain/main_processor.rs b/main/src/domain/main_processor.rs index 907c26b82..4af99f78c 100644 --- a/main/src/domain/main_processor.rs +++ b/main/src/domain/main_processor.rs @@ -18,9 +18,10 @@ use crate::domain::{ RealTimeTargetUpdate, RealearnModeContext, RealearnMonitoringFxParameterValueChangedEvent, RealearnParameterChangePayload, RealearnSourceContext, ReaperConfigChange, ReaperMessage, ReaperSourceFeedbackValue, ReaperTarget, SharedInstance, SharedUnit, SourceFeedbackEvent, - SourceFeedbackLogger, SourceReleasedEvent, SpecificCompoundFeedbackValue, TargetControlEvent, - TargetValueChangedEvent, UnitContainer, UnitEvent, UnitOrchestrationEvent, - UpdatedSingleMappingOnStateEvent, VirtualControlElement, VirtualSourceValue, + SourceFeedbackLogger, SourceReleasedEvent, SpecificCompoundFeedbackValue, StreamDeckDeviceId, + StreamDeckMessage, StreamDeckSourceFeedbackValue, TargetControlEvent, TargetValueChangedEvent, + UnitContainer, UnitEvent, UnitOrchestrationEvent, UpdatedSingleMappingOnStateEvent, + VirtualControlElement, VirtualSourceValue, }; use derive_more::Display; use enum_map::EnumMap; @@ -129,6 +130,7 @@ impl FeedbackChecksum { FinalSourceFeedbackValue::Midi(v) => Self::from_midi(v), FinalSourceFeedbackValue::Osc(v) => Self::from_osc(v), FinalSourceFeedbackValue::Reaper(v) => Self::from_reaper(v), + FinalSourceFeedbackValue::StreamDeck(v) => Self::from_stream_deck(v), } } @@ -158,6 +160,15 @@ impl FeedbackChecksum { FeedbackChecksum::Hashed(hasher.finish()) } + fn from_stream_deck(v: &StreamDeckSourceFeedbackValue) -> Self { + let mut hasher = hash_util::create_non_crypto_hasher(); + // Doesn't implement Hash, probably because it contains floating point numbers. + // We don't care about floating point hash/equality issues because we just want a checksum + // for comparing current feedback with last feedback. + v.hash(&mut hasher); + FeedbackChecksum::Hashed(hasher.finish()) + } + fn from_reaper(v: &ReaperSourceFeedbackValue) -> Self { match v { ReaperSourceFeedbackValue::Speech(s) => { @@ -1445,6 +1456,8 @@ impl MainProcessor { self.basics .update_settings_internal(settings, any_main_mapping_is_effectively_on); self.potentially_enable_or_disable_control_or_feedback(any_main_mapping_is_effectively_on); + Backbone::get() + .register_stream_deck_usage(self.basics.unit_id, settings.streamdeck_device_id); } fn update_all_mappings( @@ -1931,6 +1944,23 @@ impl MainProcessor { } } + /// This doesn't check if control enabled! You need to check before. + pub fn process_incoming_stream_deck_msg(&mut self, evt: ControlEvent) { + if self.basics.settings.real_input_logging_enabled { + self.log_incoming_message(evt); + } + self.process_incoming_message_internal(evt.map_payload(MainSourceMessage::StreamDeck)); + } + + pub fn wants_stream_deck_input_from(&self, dev: StreamDeckDeviceId) -> bool { + self.wants_messages_in_general() + && self + .basics + .settings + .streamdeck_device_id + .is_some_and(|d| d == dev) + } + fn process_incoming_msg_for_controlling( &mut self, evt: ControlEvent, @@ -2869,6 +2899,7 @@ pub enum NormalMainTask { pub struct BasicSettings { pub control_input: ControlInput, pub wants_keyboard_input: bool, + pub streamdeck_device_id: Option, pub feedback_output: Option, pub real_input_logging_enabled: bool, pub real_output_logging_enabled: bool, @@ -3896,6 +3927,11 @@ impl Basics { (FinalSourceFeedbackValue::Reaper(ReaperSourceFeedbackValue::Speech(v)), _) => { let _ = say(v); } + (FinalSourceFeedbackValue::StreamDeck(v), _) => { + if let Some(dev_id) = self.settings.streamdeck_device_id { + let _ = Backbone::get().send_stream_deck_feedback(dev_id, v); + } + } _ => {} } } diff --git a/main/src/domain/mapping.rs b/main/src/domain/mapping.rs index 8bb713767..bed6f4576 100644 --- a/main/src/domain/mapping.rs +++ b/main/src/domain/mapping.rs @@ -7,10 +7,11 @@ use crate::domain::{ Mode, OscDeviceId, OscScanResult, PersistentMappingProcessingState, PluginParamIndex, PluginParams, RealTimeMappingUpdate, RealTimeReaperTarget, RealTimeTargetUpdate, RealearnParameterChangePayload, RealearnParameterSource, RealearnSourceContext, RealearnTarget, - ReaperMessage, ReaperSource, ReaperSourceFeedbackValue, ReaperTarget, ReaperTargetType, Tag, - TargetCharacter, TrackExclusivity, UnresolvedReaperTarget, VirtualControlElement, - VirtualFeedbackValue, VirtualSource, VirtualSourceAddress, VirtualSourceValue, VirtualTarget, - COMPARTMENT_PARAMETER_COUNT, + ReaperMessage, ReaperSource, ReaperSourceFeedbackValue, ReaperTarget, ReaperTargetType, + StreamDeckDeviceId, StreamDeckMessage, StreamDeckScanResult, StreamDeckSource, + StreamDeckSourceAddress, StreamDeckSourceFeedbackValue, Tag, TargetCharacter, TrackExclusivity, + UnresolvedReaperTarget, VirtualControlElement, VirtualFeedbackValue, VirtualSource, + VirtualSourceAddress, VirtualSourceValue, VirtualTarget, COMPARTMENT_PARAMETER_COUNT, }; use derive_more::Display; use enum_map::Enum; @@ -1300,6 +1301,9 @@ impl MainMapping { s.control(m, compartment).map(ControlOutcome::Matched) } (MainSourceMessage::Key(m), CompoundMappingSource::Key(s)) => s.control(m), + (MainSourceMessage::StreamDeck(m), CompoundMappingSource::StreamDeck(s)) => { + s.control(m).map(ControlOutcome::Matched) + } _ => None, } } @@ -1341,6 +1345,7 @@ pub enum MainSourceMessage<'a> { Osc(&'a OscMessage), Reaper(&'a ReaperMessage), Key(KeyMessage), + StreamDeck(StreamDeckMessage), } impl<'a> MainSourceMessage<'a> { @@ -1354,6 +1359,10 @@ impl<'a> MainSourceMessage<'a> { dev_id: None, }), Key(msg) => MessageCaptureResult::Keyboard(msg), + StreamDeck(msg) => MessageCaptureResult::StreamDeck(StreamDeckScanResult { + message: msg, + dev_id: None, + }), Reaper(msg) => { use ReaperMessage::*; match msg { @@ -1593,6 +1602,7 @@ pub enum CompoundMappingSource { Virtual(VirtualSource), Reaper(ReaperSource), Key(KeySource), + StreamDeck(StreamDeckSource), } #[derive(Clone, Eq, PartialEq, Hash, Debug)] @@ -1601,6 +1611,7 @@ pub enum CompoundMappingSourceAddress { Osc(OscSourceAddress), Virtual(VirtualSourceAddress), Reaper(ReaperSourceAddress), + StreamDeck(StreamDeckSourceAddress), } #[derive(Clone, Eq, PartialEq, Hash, Debug)] @@ -1658,6 +1669,9 @@ impl CompoundMappingSource { Reaper(s) => s .extract_feedback_address() .map(CompoundMappingSourceAddress::Reaper), + StreamDeck(s) => Some(CompoundMappingSourceAddress::StreamDeck( + s.feedback_address(), + )), _ => None, } } @@ -1678,6 +1692,9 @@ impl CompoundMappingSource { (Midi(s), FinalSourceFeedbackValue::Midi(v)) => { s.has_same_feedback_address_as_value(v, source_context) } + (StreamDeck(s), FinalSourceFeedbackValue::StreamDeck(v)) => { + s.has_same_feedback_address_as_value(v) + } _ => false, } } @@ -1696,6 +1713,7 @@ impl CompoundMappingSource { match (self, other) { (Osc(s1), Osc(s2)) => s1.has_same_feedback_address_as_source(s2), (Midi(s1), Midi(s2)) => s1.has_same_feedback_address_as_source(s2, source_context), + (StreamDeck(s1), StreamDeck(s2)) => s1.has_same_feedback_address_as_source(s2), (Virtual(s1), Virtual(s2)) => s1.has_same_feedback_address_as_source(s2), _ => false, } @@ -1725,6 +1743,9 @@ impl CompoundMappingSource { (Key(s), IncomingCompoundSourceValue::Key(m)) => { s.reacts_to_message_with(m).map(ControlResult::Processed) } + (StreamDeck(s), IncomingCompoundSourceValue::StreamDeck(m)) => { + s.control(m).map(ControlResult::Processed) + } _ => None, } } @@ -1746,6 +1767,11 @@ impl CompoundMappingSource { let key_source = KeySource::new(msg.stroke()); Self::Key(key_source) } + StreamDeck(scan_result) => { + let source = + StreamDeckSource::new(scan_result.message.button_index, Default::default()); + Self::StreamDeck(source) + } RealearnParameter(payload) => { let reaper_source = ReaperSource::RealearnParameter(RealearnParameterSource { parameter_index: payload.parameter_index, @@ -1763,7 +1789,9 @@ impl CompoundMappingSource { Virtual(s) => s.format_control_value(value), Osc(s) => s.format_control_value(value), Reaper(s) => s.format_control_value(value), - Never | Key(_) => Ok(format_percentage_without_unit(value.to_unit_value()?.get())), + Never | Key(_) | StreamDeck(_) => { + Ok(format_percentage_without_unit(value.to_unit_value()?.get())) + } } } @@ -1774,7 +1802,7 @@ impl CompoundMappingSource { Virtual(s) => s.parse_control_value(text), Osc(s) => s.parse_control_value(text), Reaper(s) => s.parse_control_value(text), - Never | Key(_) => parse_percentage_without_unit(text)?.try_into(), + Never | Key(_) | StreamDeck(_) => parse_percentage_without_unit(text)?.try_into(), } } @@ -1786,7 +1814,9 @@ impl CompoundMappingSource { Osc(s) => ExtendedSourceCharacter::Normal(s.character()), Reaper(s) => ExtendedSourceCharacter::Normal(s.character()), Never => ExtendedSourceCharacter::VirtualContinuous, - Key(_) => ExtendedSourceCharacter::Normal(SourceCharacter::MomentaryButton), + Key(_) | StreamDeck(_) => { + ExtendedSourceCharacter::Normal(SourceCharacter::MomentaryButton) + } } } @@ -1806,6 +1836,9 @@ impl CompoundMappingSource { Reaper(s) => s .feedback(&feedback_value) .map(PreliminarySourceFeedbackValue::Reaper), + StreamDeck(s) => s + .feedback(&feedback_value) + .map(PreliminarySourceFeedbackValue::StreamDeck), // This is handled in a special way by consumers. Virtual(_) => None, // No feedback for other sources. @@ -1817,7 +1850,7 @@ impl CompoundMappingSource { use CompoundMappingSource::*; match self { Midi(s) => s.consumes(msg), - Reaper(_) | Virtual(_) | Osc(_) | Never | Key(_) => false, + Reaper(_) | Virtual(_) | Osc(_) | Never | Key(_) | StreamDeck(_) => false, } } @@ -1831,7 +1864,7 @@ impl CompoundMappingSource { Midi(s) => s.max_discrete_value(), // TODO-medium OSC will also support discrete values as soon as we allow integers and // configuring max values - Reaper(_) | Virtual(_) | Osc(_) | Never | Key(_) => None, + Reaper(_) | Virtual(_) | Osc(_) | Never | Key(_) | StreamDeck(_) => None, } } } @@ -1989,6 +2022,7 @@ pub enum PreliminarySourceFeedbackValue { Midi(PreliminaryMidiSourceFeedbackValue<'static, RawShortMessage>), Osc(OscMessage), Reaper(ReaperSourceFeedbackValue), + StreamDeck(StreamDeckSourceFeedbackValue), } #[derive(Clone, PartialEq, Debug)] @@ -1996,6 +2030,7 @@ pub enum FinalSourceFeedbackValue { Midi(MidiSourceValue<'static, RawShortMessage>), Osc(OscMessage), Reaper(ReaperSourceFeedbackValue), + StreamDeck(StreamDeckSourceFeedbackValue), } impl FinalSourceFeedbackValue { @@ -2010,6 +2045,9 @@ impl FinalSourceFeedbackValue { FinalSourceFeedbackValue::Reaper(v) => v .extract_feedback_address() .map(CompoundMappingSourceAddress::Reaper), + FinalSourceFeedbackValue::StreamDeck(v) => Some( + CompoundMappingSourceAddress::StreamDeck(v.feedback_address()), + ), } } } @@ -2676,6 +2714,7 @@ pub enum MessageCaptureResult { Midi(MidiScanResult), Osc(OscScanResult), Keyboard(KeyMessage), + StreamDeck(StreamDeckScanResult), RealearnParameter(RealearnParameterChangePayload), } @@ -2689,6 +2728,7 @@ impl MessageCaptureResult { Midi(res) => IncomingCompoundSourceValue::Midi(&res.value), Osc(res) => IncomingCompoundSourceValue::Osc(&res.message), Keyboard(res) => IncomingCompoundSourceValue::Key(*res), + StreamDeck(res) => IncomingCompoundSourceValue::StreamDeck(res.message), RealearnParameter(payload) => IncomingCompoundSourceValue::RealearnParameter(*payload), } } @@ -2709,6 +2749,9 @@ impl MessageCaptureResult { device_id: r.dev_id?, }, Keyboard(_) => InputDescriptor::Keyboard, + StreamDeck(r) => InputDescriptor::StreamDeck { + device_id: r.dev_id?, + }, RealearnParameter(_) => return None, }; Some(res) @@ -2721,6 +2764,7 @@ pub enum IncomingCompoundSourceValue<'a> { Osc(&'a OscMessage), Virtual(&'a VirtualSourceValue), Key(KeyMessage), + StreamDeck(StreamDeckMessage), RealearnParameter(RealearnParameterChangePayload), } @@ -2733,6 +2777,9 @@ pub enum InputDescriptor { device_id: OscDeviceId, }, Keyboard, + StreamDeck { + device_id: StreamDeckDeviceId, + }, } #[derive(Copy, Clone)] diff --git a/main/src/domain/mod.rs b/main/src/domain/mod.rs index 39fc49af9..38949e993 100644 --- a/main/src/domain/mod.rs +++ b/main/src/domain/mod.rs @@ -114,6 +114,12 @@ pub use reaper_source::*; mod key_source; pub use key_source::*; +mod stream_deck_device; +pub use stream_deck_device::*; + +mod stream_deck_source; +pub use stream_deck_source::*; + mod device_change_detector; pub use device_change_detector::*; @@ -172,4 +178,5 @@ mod hex; pub use hex::*; mod global_audio_state; + pub use global_audio_state::*; diff --git a/main/src/domain/stream_deck_device.rs b/main/src/domain/stream_deck_device.rs new file mode 100644 index 000000000..3a5a8b336 --- /dev/null +++ b/main/src/domain/stream_deck_device.rs @@ -0,0 +1,92 @@ +use crate::domain::UnitId; +use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet}; +use hidapi::HidApi; +use serde::{Deserialize, Serialize}; +use streamdeck::{pids, StreamDeck}; + +pub struct ProbedStreamDeckDevice { + pub dev: StreamDeckDevice, + pub available: bool, +} + +#[derive(Copy, Clone, Debug)] +pub struct StreamDeckDevice { + pub id: StreamDeckDeviceId, + pub name: &'static str, +} + +impl StreamDeckDevice { + pub const fn new(vid: u16, pid: u16, name: &'static str) -> Self { + let id = StreamDeckDeviceId { vid, pid }; + Self { id, name } + } +} + +pub fn probe_stream_deck_devices() -> anyhow::Result> { + let mut api = HidApi::new()?; + api.refresh_devices()?; + let connected_devs: NonCryptoHashSet<_> = api + .device_list() + .map(|info| StreamDeckDeviceId { + vid: info.vendor_id(), + pid: info.product_id(), + }) + .collect(); + let probed_devs = SUPPORTED_DEVICES + .iter() + .copied() + .map(|dev| ProbedStreamDeckDevice { + dev, + available: connected_devs.contains(&dev.id), + }) + .collect(); + Ok(probed_devs) +} + +const ELGATO_VENDOR_ID: u16 = 0x0fd9; + +const SUPPORTED_DEVICES: &[StreamDeckDevice] = &[ + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::ORIGINAL, "Original"), + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::ORIGINAL_V2, "Original V2"), + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::MINI, "Mini"), + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::XL, "XL"), + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::MK2, "MK2"), + StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::REVISED_MINI, "Revised Mini"), +]; + +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] +pub struct StreamDeckDeviceId { + /// Vendor ID. + pub vid: u16, + /// Product ID. + pub pid: u16, + // Serial number (for distinguishing between multiple devices of the same type). + // pub serial_number: Option, +} + +impl StreamDeckDeviceId { + pub fn connect(&self) -> Result { + let mut sd = StreamDeck::connect(self.vid, self.pid, None)?; + sd.set_blocking(false)?; + Ok(sd) + } +} + +#[derive(Debug, Default)] +pub struct StreamDeckDeviceManager { + device_usage: NonCryptoHashMap, +} + +impl StreamDeckDeviceManager { + pub fn register_device_usage(&mut self, unit_id: UnitId, device: Option) { + if let Some(d) = device { + self.device_usage.insert(unit_id, d); + } else { + self.device_usage.remove(&unit_id); + } + } + + pub fn devices_in_use(&self) -> NonCryptoHashSet { + self.device_usage.values().copied().collect() + } +} diff --git a/main/src/domain/stream_deck_source.rs b/main/src/domain/stream_deck_source.rs new file mode 100644 index 000000000..de6617aee --- /dev/null +++ b/main/src/domain/stream_deck_source.rs @@ -0,0 +1,164 @@ +use crate::domain::{OscDeviceId, StreamDeckDeviceId}; +use derivative::Derivative; +use helgoboss_learn::{ControlValue, FeedbackValue, RgbColor, UnitValue}; +use helgobox_api::persistence::StreamDeckButtonDesign; +use rosc::OscMessage; +use std::fmt::{Display, Formatter}; +use std::hash::{Hash, Hasher}; + +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct StreamDeckSource { + pub button_index: u32, + pub button_design: StreamDeckButtonDesign, +} + +impl StreamDeckSource { + pub fn new(button_index: u32, button_design: StreamDeckButtonDesign) -> Self { + Self { + button_index, + button_design, + } + } + + pub fn feedback_address(&self) -> StreamDeckSourceAddress { + StreamDeckSourceAddress { + button_index: self.button_index, + } + } + + /// Checks if the given message is directed to the same address as the one of this source. + /// + /// Used for: + /// + /// - Source takeover (feedback) + pub fn has_same_feedback_address_as_value( + &self, + value: &StreamDeckSourceFeedbackValue, + ) -> bool { + self.feedback_address() == value.feedback_address() + } + + /// Checks if this and the given source share the same address. + /// + /// Used for: + /// + /// - Feedback diffing + pub fn has_same_feedback_address_as_source(&self, other: &Self) -> bool { + self.feedback_address() == other.feedback_address() + } + + pub fn control(&self, msg: StreamDeckMessage) -> Option { + if msg.button_index != self.button_index { + return None; + } + let val = if msg.press { + UnitValue::MAX + } else { + UnitValue::MIN + }; + Some(ControlValue::AbsoluteContinuous(val)) + } + + pub fn feedback( + &self, + feedback_value: &FeedbackValue, + ) -> Option { + let value = match feedback_value { + FeedbackValue::Off => StreamDeckSourceFeedbackValue { + button_index: self.button_index, + button_design: self.button_design.clone(), + background_color: Some(RgbColor::BLACK), + foreground_color: None, + numeric_value: None, + text_value: None, + }, + FeedbackValue::Numeric(v) => StreamDeckSourceFeedbackValue { + button_index: self.button_index, + button_design: self.button_design.clone(), + background_color: v.style.background_color, + foreground_color: v.style.color, + numeric_value: Some(v.value.to_unit_value()), + text_value: None, + }, + FeedbackValue::Textual(v) => StreamDeckSourceFeedbackValue { + button_index: self.button_index, + button_design: self.button_design.clone(), + background_color: v.style.background_color, + foreground_color: v.style.color, + numeric_value: None, + text_value: Some(v.text.to_string()), + }, + FeedbackValue::Complex(_) => { + // TODO-high CONTINUE supporting complex dynamically generated feedback (by glue section) + return None; + } + }; + Some(value) + } +} + +impl Display for StreamDeckSource { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Button {}", self.button_index + 1) + } +} + +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub struct StreamDeckMessage { + pub button_index: u32, + pub press: bool, +} + +impl StreamDeckMessage { + pub fn new(button_index: u32, press: bool) -> Self { + Self { + button_index, + press, + } + } +} + +impl Display for StreamDeckMessage { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.button_index, self.press) + } +} + +#[derive(Clone, Eq, PartialEq, Debug, Derivative)] +#[derivative(Hash)] +pub struct StreamDeckSourceFeedbackValue { + pub button_index: u32, + pub button_design: StreamDeckButtonDesign, + pub background_color: Option, + pub foreground_color: Option, + #[derivative(Hash(hash_with = "hash_opt_unit_value_for_change_detection"))] + pub numeric_value: Option, + pub text_value: Option, +} + +fn hash_opt_unit_value_for_change_detection(value: &Option, state: &mut H) +where + H: Hasher, +{ + let raw = value.map(|v| v.get().to_ne_bytes()); + raw.hash(state); +} + +impl StreamDeckSourceFeedbackValue { + pub fn feedback_address(&self) -> StreamDeckSourceAddress { + StreamDeckSourceAddress { + button_index: self.button_index, + } + } +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct StreamDeckSourceAddress { + pub button_index: u32, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct StreamDeckScanResult { + pub message: StreamDeckMessage, + pub dev_id: Option, +} diff --git a/main/src/infrastructure/api/convert/from_data/source.rs b/main/src/infrastructure/api/convert/from_data/source.rs index bccd4539e..5a8b4e5b9 100644 --- a/main/src/infrastructure/api/convert/from_data/source.rs +++ b/main/src/infrastructure/api/convert/from_data/source.rs @@ -259,6 +259,13 @@ pub fn convert_source( }; persistence::Source::Key(s) } + StreamDeck => { + let s = persistence::StreamDeckSource { + button_index: data.button_index, + button_design: data.button_design, + }; + persistence::Source::StreamDeck(s) + } }; Ok(source) } diff --git a/main/src/infrastructure/api/convert/to_data/source.rs b/main/src/infrastructure/api/convert/to_data/source.rs index 1bfa24f74..6826a742d 100644 --- a/main/src/infrastructure/api/convert/to_data/source.rs +++ b/main/src/infrastructure/api/convert/to_data/source.rs @@ -125,6 +125,14 @@ pub fn convert_source(s: Source) -> ConversionResult { Source::Key(s) => s.keystroke.map(convert_keystroke), _ => Default::default(), }, + button_index: match &s { + Source::StreamDeck(s) => s.button_index, + _ => Default::default(), + }, + button_design: match &s { + Source::StreamDeck(s) => s.button_design.clone(), + _ => Default::default(), + }, control_element_type: match &s { Source::Virtual(s) => s.character.unwrap_or_default(), _ => Default::default(), @@ -182,6 +190,7 @@ fn convert_category(s: &Source) -> SourceCategory { | LaunchpadProScrollingTextDisplay => SourceCategory::Midi, Osc(_) => SourceCategory::Osc, Key(_) => SourceCategory::Keyboard, + StreamDeck(_) => SourceCategory::StreamDeck, Virtual(_) => SourceCategory::Virtual, } } diff --git a/main/src/infrastructure/data/source_model_data.rs b/main/src/infrastructure/data/source_model_data.rs index 52ce29628..7ddb4e2f7 100644 --- a/main/src/infrastructure/data/source_model_data.rs +++ b/main/src/infrastructure/data/source_model_data.rs @@ -9,7 +9,9 @@ use crate::infrastructure::data::VirtualControlElementIdData; use base::default_util::{deserialize_null_default, is_default}; use helgoboss_learn::{DisplayType, MidiClockTransportMessage, OscTypeTag, SourceCharacter}; use helgoboss_midi::{Channel, U14, U7}; -use helgobox_api::persistence::{MidiScriptKind, VirtualControlElementCharacter}; +use helgobox_api::persistence::{ + MidiScriptKind, StreamDeckButtonDesign, VirtualControlElementCharacter, +}; use semver::Version; use serde::{Deserialize, Serialize}; use std::convert::TryInto; @@ -149,6 +151,11 @@ pub struct SourceModelData { skip_serializing_if = "is_default" )] pub keystroke: Option, + // StreamDeck + #[serde(default)] + pub button_index: u32, + #[serde(default)] + pub button_design: StreamDeckButtonDesign, // Virtual #[serde( default, @@ -211,6 +218,8 @@ impl SourceModelData { osc_arg_value_range: OscValueRange::from_interval(model.osc_arg_value_range()), osc_feedback_args: model.osc_feedback_args().to_vec(), keystroke: model.keystroke(), + button_index: model.button_index(), + button_design: model.create_stream_deck_button_design(), control_element_type: model.control_element_character(), control_element_index: VirtualControlElementIdData::from_model( model.control_element_id(), @@ -299,6 +308,13 @@ impl SourceModelData { model.change(P::SetTimerMillis(self.timer_millis)); model.change(P::SetParameterIndex(self.parameter_index)); model.change(P::SetKeystroke(self.keystroke)); + model.change(P::SetButtonIndex(self.button_index)); + model.change(P::SetButtonBackgroundType( + (&self.button_design.background).into(), + )); + model.change(P::SetButtonForegroundType( + (&self.button_design.foreground).into(), + )); } } diff --git a/main/src/infrastructure/data/unit_data.rs b/main/src/infrastructure/data/unit_data.rs index 1fb9ec0b5..8574a1cc5 100644 --- a/main/src/infrastructure/data/unit_data.rs +++ b/main/src/infrastructure/data/unit_data.rs @@ -8,7 +8,7 @@ use crate::domain::{ compartment_param_index_iter, CompartmentKind, CompartmentParamIndex, CompartmentParams, ControlInput, FeedbackOutput, GroupId, GroupKey, MappingId, MappingKey, MappingSnapshotContainer, MappingSnapshotId, MidiControlInput, MidiDestination, OscDeviceId, - Param, PluginParams, StayActiveWhenProjectInBackground, Tag, Unit, + Param, PluginParams, StayActiveWhenProjectInBackground, StreamDeckDeviceId, Tag, Unit, }; use crate::infrastructure::data::{ convert_target_value_to_api, convert_target_value_to_model, @@ -107,6 +107,12 @@ pub struct UnitData { skip_serializing_if = "is_default" )] wants_keyboard_input: bool, + #[serde( + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "is_default" + )] + stream_deck_device_id: Option, /// /// - `None` means "\" /// - `Some("fx-output")` means "\" @@ -420,6 +426,7 @@ impl Default for UnitData { session_defaults::RESET_FEEDBACK_WHEN_RELEASING_SOURCE, control_device_id: None, wants_keyboard_input: session_defaults::WANTS_KEYBOARD_INPUT, + stream_deck_device_id: None, feedback_device_id: None, default_group: None, default_controller_group: None, @@ -511,6 +518,7 @@ impl UnitData { } }, wants_keyboard_input: session.wants_keyboard_input(), + stream_deck_device_id: session.stream_deck_device_id(), feedback_device_id: { session.feedback_output().map(|output| match output { FeedbackOutput::Midi(MidiDestination::FxOutput) => { @@ -710,6 +718,9 @@ impl UnitData { let _ = session.change(SessionCommand::SetWantsKeyboardInput( self.wants_keyboard_input || wants_keyboard_input_legacy, )); + let _ = session.change(SessionCommand::SetStreamDeckDevice( + self.stream_deck_device_id, + )); // Let events through or not { let is_old_preset = self diff --git a/main/src/infrastructure/ui/bindings.rs b/main/src/infrastructure/ui/bindings.rs index 01e044004..e009b2e44 100644 --- a/main/src/infrastructure/ui/bindings.rs +++ b/main/src/infrastructure/ui/bindings.rs @@ -107,7 +107,7 @@ pub mod root { pub const ID_CLEAR_SOURCE_FILTER_BUTTON: u32 = 30037; pub const ID_FILTER_BY_TARGET_BUTTON: u32 = 30038; pub const ID_CLEAR_TARGET_FILTER_BUTTON: u32 = 30039; - pub const ID_MAPPING_PANEL: u32 = 30190; + pub const ID_MAPPING_PANEL: u32 = 30189; pub const ID_MAPPING_PANEL_MAPPING_LABEL: u32 = 30043; pub const ID_MAPPING_PANEL_FEEDBACK_LABEL: u32 = 30044; pub const ID_MAPPING_FEEDBACK_SEND_BEHAVIOR_COMBO_BOX: u32 = 30045; @@ -124,199 +124,198 @@ pub mod root { pub const ID_SOURCE_CHANNEL_LABEL: u32 = 30056; pub const ID_SOURCE_CHANNEL_COMBO_BOX: u32 = 30057; pub const ID_SOURCE_LINE_3_EDIT_CONTROL: u32 = 30058; - pub const ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX: u32 = 30059; - pub const ID_SOURCE_NOTE_OR_CC_NUMBER_LABEL_TEXT: u32 = 30060; - pub const ID_SOURCE_RPN_CHECK_BOX: u32 = 30061; - pub const ID_SOURCE_LINE_4_COMBO_BOX_1: u32 = 30062; - pub const ID_SOURCE_NUMBER_EDIT_CONTROL: u32 = 30063; - pub const ID_SOURCE_NUMBER_COMBO_BOX: u32 = 30064; - pub const ID_SOURCE_LINE_4_BUTTON: u32 = 30065; - pub const ID_SOURCE_CHARACTER_LABEL_TEXT: u32 = 30066; - pub const ID_SOURCE_CHARACTER_COMBO_BOX: u32 = 30067; - pub const ID_SOURCE_LINE_5_EDIT_CONTROL: u32 = 30068; - pub const ID_SOURCE_14_BIT_CHECK_BOX: u32 = 30069; - pub const ID_SOURCE_OSC_ADDRESS_LABEL_TEXT: u32 = 30070; - pub const ID_SOURCE_OSC_ADDRESS_PATTERN_EDIT_CONTROL: u32 = 30071; - pub const ID_SOURCE_SCRIPT_DETAIL_BUTTON: u32 = 30072; - pub const ID_MAPPING_PANEL_TARGET_LABEL: u32 = 30073; - pub const ID_TARGET_LEARN_BUTTON: u32 = 30074; - pub const ID_TARGET_MENU_BUTTON: u32 = 30075; - pub const ID_TARGET_HINT: u32 = 30076; - pub const ID_MAPPING_PANEL_TARGET_TYPE_LABEL: u32 = 30077; - pub const ID_TARGET_CATEGORY_COMBO_BOX: u32 = 30078; - pub const ID_TARGET_TYPE_BUTTON: u32 = 30079; - pub const ID_TARGET_LINE_2_LABEL_2: u32 = 30080; - pub const ID_TARGET_LINE_2_LABEL_3: u32 = 30081; - pub const ID_TARGET_LINE_2_LABEL_1: u32 = 30082; - pub const ID_TARGET_LINE_2_COMBO_BOX_1: u32 = 30083; - pub const ID_TARGET_LINE_2_EDIT_CONTROL: u32 = 30084; - pub const ID_TARGET_LINE_2_COMBO_BOX_2: u32 = 30085; - pub const ID_TARGET_LINE_2_BUTTON: u32 = 30086; - pub const ID_TARGET_LINE_3_LABEL_1: u32 = 30087; - pub const ID_TARGET_LINE_3_COMBO_BOX_1: u32 = 30088; - pub const ID_TARGET_LINE_3_EDIT_CONTROL: u32 = 30089; - pub const ID_TARGET_LINE_3_COMBO_BOX_2: u32 = 30090; - pub const ID_TARGET_LINE_3_LABEL_2: u32 = 30091; - pub const ID_TARGET_LINE_3_LABEL_3: u32 = 30092; - pub const ID_TARGET_LINE_3_BUTTON: u32 = 30093; - pub const ID_TARGET_LINE_4_LABEL_1: u32 = 30094; - pub const ID_TARGET_LINE_4_COMBO_BOX_1: u32 = 30095; - pub const ID_TARGET_LINE_4_EDIT_CONTROL: u32 = 30096; - pub const ID_TARGET_LINE_4_COMBO_BOX_2: u32 = 30097; - pub const ID_TARGET_LINE_4_LABEL_2: u32 = 30098; - pub const ID_TARGET_LINE_4_BUTTON: u32 = 30099; - pub const ID_TARGET_LINE_4_LABEL_3: u32 = 30100; - pub const ID_TARGET_LINE_5_LABEL_1: u32 = 30101; - pub const ID_TARGET_LINE_5_EDIT_CONTROL: u32 = 30102; - pub const ID_TARGET_CHECK_BOX_1: u32 = 30103; - pub const ID_TARGET_CHECK_BOX_2: u32 = 30104; - pub const ID_TARGET_CHECK_BOX_3: u32 = 30105; - pub const ID_TARGET_CHECK_BOX_4: u32 = 30106; - pub const ID_TARGET_CHECK_BOX_5: u32 = 30107; - pub const ID_TARGET_CHECK_BOX_6: u32 = 30108; - pub const ID_TARGET_VALUE_LABEL_TEXT: u32 = 30109; - pub const ID_TARGET_VALUE_OFF_BUTTON: u32 = 30110; - pub const ID_TARGET_VALUE_ON_BUTTON: u32 = 30111; - pub const ID_TARGET_VALUE_SLIDER_CONTROL: u32 = 30112; - pub const ID_TARGET_VALUE_EDIT_CONTROL: u32 = 30113; - pub const ID_TARGET_VALUE_TEXT: u32 = 30114; - pub const ID_TARGET_UNIT_BUTTON: u32 = 30115; - pub const ID_MAPPING_PANEL_GLUE_LABEL: u32 = 30116; - pub const ID_SETTINGS_RESET_BUTTON: u32 = 30117; - pub const ID_SETTINGS_SOURCE_LABEL: u32 = 30118; + pub const ID_SOURCE_NOTE_OR_CC_NUMBER_LABEL_TEXT: u32 = 30059; + pub const ID_SOURCE_RPN_CHECK_BOX: u32 = 30060; + pub const ID_SOURCE_LINE_4_COMBO_BOX_1: u32 = 30061; + pub const ID_SOURCE_NUMBER_EDIT_CONTROL: u32 = 30062; + pub const ID_SOURCE_NUMBER_COMBO_BOX: u32 = 30063; + pub const ID_SOURCE_LINE_4_BUTTON: u32 = 30064; + pub const ID_SOURCE_CHARACTER_LABEL_TEXT: u32 = 30065; + pub const ID_SOURCE_CHARACTER_COMBO_BOX: u32 = 30066; + pub const ID_SOURCE_LINE_5_EDIT_CONTROL: u32 = 30067; + pub const ID_SOURCE_14_BIT_CHECK_BOX: u32 = 30068; + pub const ID_SOURCE_OSC_ADDRESS_LABEL_TEXT: u32 = 30069; + pub const ID_SOURCE_OSC_ADDRESS_PATTERN_EDIT_CONTROL: u32 = 30070; + pub const ID_SOURCE_SCRIPT_DETAIL_BUTTON: u32 = 30071; + pub const ID_MAPPING_PANEL_TARGET_LABEL: u32 = 30072; + pub const ID_TARGET_LEARN_BUTTON: u32 = 30073; + pub const ID_TARGET_MENU_BUTTON: u32 = 30074; + pub const ID_TARGET_HINT: u32 = 30075; + pub const ID_MAPPING_PANEL_TARGET_TYPE_LABEL: u32 = 30076; + pub const ID_TARGET_CATEGORY_COMBO_BOX: u32 = 30077; + pub const ID_TARGET_TYPE_BUTTON: u32 = 30078; + pub const ID_TARGET_LINE_2_LABEL_2: u32 = 30079; + pub const ID_TARGET_LINE_2_LABEL_3: u32 = 30080; + pub const ID_TARGET_LINE_2_LABEL_1: u32 = 30081; + pub const ID_TARGET_LINE_2_COMBO_BOX_1: u32 = 30082; + pub const ID_TARGET_LINE_2_EDIT_CONTROL: u32 = 30083; + pub const ID_TARGET_LINE_2_COMBO_BOX_2: u32 = 30084; + pub const ID_TARGET_LINE_2_BUTTON: u32 = 30085; + pub const ID_TARGET_LINE_3_LABEL_1: u32 = 30086; + pub const ID_TARGET_LINE_3_COMBO_BOX_1: u32 = 30087; + pub const ID_TARGET_LINE_3_EDIT_CONTROL: u32 = 30088; + pub const ID_TARGET_LINE_3_COMBO_BOX_2: u32 = 30089; + pub const ID_TARGET_LINE_3_LABEL_2: u32 = 30090; + pub const ID_TARGET_LINE_3_LABEL_3: u32 = 30091; + pub const ID_TARGET_LINE_3_BUTTON: u32 = 30092; + pub const ID_TARGET_LINE_4_LABEL_1: u32 = 30093; + pub const ID_TARGET_LINE_4_COMBO_BOX_1: u32 = 30094; + pub const ID_TARGET_LINE_4_EDIT_CONTROL: u32 = 30095; + pub const ID_TARGET_LINE_4_COMBO_BOX_2: u32 = 30096; + pub const ID_TARGET_LINE_4_LABEL_2: u32 = 30097; + pub const ID_TARGET_LINE_4_BUTTON: u32 = 30098; + pub const ID_TARGET_LINE_4_LABEL_3: u32 = 30099; + pub const ID_TARGET_LINE_5_LABEL_1: u32 = 30100; + pub const ID_TARGET_LINE_5_EDIT_CONTROL: u32 = 30101; + pub const ID_TARGET_CHECK_BOX_1: u32 = 30102; + pub const ID_TARGET_CHECK_BOX_2: u32 = 30103; + pub const ID_TARGET_CHECK_BOX_3: u32 = 30104; + pub const ID_TARGET_CHECK_BOX_4: u32 = 30105; + pub const ID_TARGET_CHECK_BOX_5: u32 = 30106; + pub const ID_TARGET_CHECK_BOX_6: u32 = 30107; + pub const ID_TARGET_VALUE_LABEL_TEXT: u32 = 30108; + pub const ID_TARGET_VALUE_OFF_BUTTON: u32 = 30109; + pub const ID_TARGET_VALUE_ON_BUTTON: u32 = 30110; + pub const ID_TARGET_VALUE_SLIDER_CONTROL: u32 = 30111; + pub const ID_TARGET_VALUE_EDIT_CONTROL: u32 = 30112; + pub const ID_TARGET_VALUE_TEXT: u32 = 30113; + pub const ID_TARGET_UNIT_BUTTON: u32 = 30114; + pub const ID_MAPPING_PANEL_GLUE_LABEL: u32 = 30115; + pub const ID_SETTINGS_RESET_BUTTON: u32 = 30116; + pub const ID_SETTINGS_SOURCE_LABEL: u32 = 30117; #[allow(dead_code)] - pub const ID_SETTINGS_SOURCE_GROUP: u32 = 30119; - pub const ID_SETTINGS_SOURCE_MIN_LABEL: u32 = 30120; - pub const ID_SETTINGS_MIN_SOURCE_VALUE_SLIDER_CONTROL: u32 = 30121; - pub const ID_SETTINGS_MIN_SOURCE_VALUE_EDIT_CONTROL: u32 = 30122; - pub const ID_SETTINGS_SOURCE_MAX_LABEL: u32 = 30123; - pub const ID_SETTINGS_MAX_SOURCE_VALUE_SLIDER_CONTROL: u32 = 30124; - pub const ID_SETTINGS_MAX_SOURCE_VALUE_EDIT_CONTROL: u32 = 30125; - pub const ID_MODE_OUT_OF_RANGE_LABEL_TEXT: u32 = 30126; - pub const ID_MODE_OUT_OF_RANGE_COMBOX_BOX: u32 = 30127; - pub const ID_MODE_GROUP_INTERACTION_LABEL_TEXT: u32 = 30128; - pub const ID_MODE_GROUP_INTERACTION_COMBO_BOX: u32 = 30129; - pub const ID_SETTINGS_TARGET_LABEL_TEXT: u32 = 30130; - pub const ID_SETTINGS_TARGET_SEQUENCE_LABEL_TEXT: u32 = 30131; - pub const ID_MODE_TARGET_SEQUENCE_EDIT_CONTROL: u32 = 30132; + pub const ID_SETTINGS_SOURCE_GROUP: u32 = 30118; + pub const ID_SETTINGS_SOURCE_MIN_LABEL: u32 = 30119; + pub const ID_SETTINGS_MIN_SOURCE_VALUE_SLIDER_CONTROL: u32 = 30120; + pub const ID_SETTINGS_MIN_SOURCE_VALUE_EDIT_CONTROL: u32 = 30121; + pub const ID_SETTINGS_SOURCE_MAX_LABEL: u32 = 30122; + pub const ID_SETTINGS_MAX_SOURCE_VALUE_SLIDER_CONTROL: u32 = 30123; + pub const ID_SETTINGS_MAX_SOURCE_VALUE_EDIT_CONTROL: u32 = 30124; + pub const ID_MODE_OUT_OF_RANGE_LABEL_TEXT: u32 = 30125; + pub const ID_MODE_OUT_OF_RANGE_COMBOX_BOX: u32 = 30126; + pub const ID_MODE_GROUP_INTERACTION_LABEL_TEXT: u32 = 30127; + pub const ID_MODE_GROUP_INTERACTION_COMBO_BOX: u32 = 30128; + pub const ID_SETTINGS_TARGET_LABEL_TEXT: u32 = 30129; + pub const ID_SETTINGS_TARGET_SEQUENCE_LABEL_TEXT: u32 = 30130; + pub const ID_MODE_TARGET_SEQUENCE_EDIT_CONTROL: u32 = 30131; #[allow(dead_code)] - pub const ID_SETTINGS_TARGET_GROUP: u32 = 30133; - pub const ID_SETTINGS_MIN_TARGET_LABEL_TEXT: u32 = 30134; - pub const ID_SETTINGS_MIN_TARGET_VALUE_SLIDER_CONTROL: u32 = 30135; - pub const ID_SETTINGS_MIN_TARGET_VALUE_EDIT_CONTROL: u32 = 30136; - pub const ID_SETTINGS_MIN_TARGET_VALUE_TEXT: u32 = 30137; - pub const ID_SETTINGS_MAX_TARGET_LABEL_TEXT: u32 = 30138; - pub const ID_SETTINGS_MAX_TARGET_VALUE_SLIDER_CONTROL: u32 = 30139; - pub const ID_SETTINGS_MAX_TARGET_VALUE_EDIT_CONTROL: u32 = 30140; - pub const ID_SETTINGS_MAX_TARGET_VALUE_TEXT: u32 = 30141; - pub const ID_SETTINGS_REVERSE_CHECK_BOX: u32 = 30142; - pub const IDC_MODE_FEEDBACK_TYPE_COMBO_BOX: u32 = 30143; - pub const ID_MODE_EEL_FEEDBACK_TRANSFORMATION_EDIT_CONTROL: u32 = 30144; - pub const IDC_MODE_FEEDBACK_TYPE_BUTTON: u32 = 30145; - pub const ID_MODE_KNOB_FADER_GROUP_BOX: u32 = 30146; - pub const ID_SETTINGS_MODE_LABEL: u32 = 30147; - pub const ID_SETTINGS_MODE_COMBO_BOX: u32 = 30148; - pub const ID_MODE_TAKEOVER_LABEL: u32 = 30149; - pub const ID_MODE_TAKEOVER_MODE: u32 = 30150; - pub const ID_SETTINGS_ROUND_TARGET_VALUE_CHECK_BOX: u32 = 30151; - pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_LABEL: u32 = 30152; - pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_EDIT_CONTROL: u32 = 30153; - pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_DETAIL_BUTTON: u32 = 30154; - pub const ID_MODE_RELATIVE_GROUP_BOX: u32 = 30155; - pub const ID_SETTINGS_STEP_SIZE_LABEL_TEXT: u32 = 30156; + pub const ID_SETTINGS_TARGET_GROUP: u32 = 30132; + pub const ID_SETTINGS_MIN_TARGET_LABEL_TEXT: u32 = 30133; + pub const ID_SETTINGS_MIN_TARGET_VALUE_SLIDER_CONTROL: u32 = 30134; + pub const ID_SETTINGS_MIN_TARGET_VALUE_EDIT_CONTROL: u32 = 30135; + pub const ID_SETTINGS_MIN_TARGET_VALUE_TEXT: u32 = 30136; + pub const ID_SETTINGS_MAX_TARGET_LABEL_TEXT: u32 = 30137; + pub const ID_SETTINGS_MAX_TARGET_VALUE_SLIDER_CONTROL: u32 = 30138; + pub const ID_SETTINGS_MAX_TARGET_VALUE_EDIT_CONTROL: u32 = 30139; + pub const ID_SETTINGS_MAX_TARGET_VALUE_TEXT: u32 = 30140; + pub const ID_SETTINGS_REVERSE_CHECK_BOX: u32 = 30141; + pub const IDC_MODE_FEEDBACK_TYPE_COMBO_BOX: u32 = 30142; + pub const ID_MODE_EEL_FEEDBACK_TRANSFORMATION_EDIT_CONTROL: u32 = 30143; + pub const IDC_MODE_FEEDBACK_TYPE_BUTTON: u32 = 30144; + pub const ID_MODE_KNOB_FADER_GROUP_BOX: u32 = 30145; + pub const ID_SETTINGS_MODE_LABEL: u32 = 30146; + pub const ID_SETTINGS_MODE_COMBO_BOX: u32 = 30147; + pub const ID_MODE_TAKEOVER_LABEL: u32 = 30148; + pub const ID_MODE_TAKEOVER_MODE: u32 = 30149; + pub const ID_SETTINGS_ROUND_TARGET_VALUE_CHECK_BOX: u32 = 30150; + pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_LABEL: u32 = 30151; + pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_EDIT_CONTROL: u32 = 30152; + pub const ID_MODE_EEL_CONTROL_TRANSFORMATION_DETAIL_BUTTON: u32 = 30153; + pub const ID_MODE_RELATIVE_GROUP_BOX: u32 = 30154; + pub const ID_SETTINGS_STEP_SIZE_LABEL_TEXT: u32 = 30155; #[allow(dead_code)] - pub const ID_SETTINGS_STEP_SIZE_GROUP: u32 = 30157; - pub const ID_SETTINGS_MIN_STEP_SIZE_LABEL_TEXT: u32 = 30158; - pub const ID_SETTINGS_MIN_STEP_SIZE_SLIDER_CONTROL: u32 = 30159; - pub const ID_SETTINGS_MIN_STEP_SIZE_EDIT_CONTROL: u32 = 30160; - pub const ID_SETTINGS_MIN_STEP_SIZE_VALUE_TEXT: u32 = 30161; - pub const ID_SETTINGS_MAX_STEP_SIZE_LABEL_TEXT: u32 = 30162; - pub const ID_SETTINGS_MAX_STEP_SIZE_SLIDER_CONTROL: u32 = 30163; - pub const ID_SETTINGS_MAX_STEP_SIZE_EDIT_CONTROL: u32 = 30164; - pub const ID_SETTINGS_MAX_STEP_SIZE_VALUE_TEXT: u32 = 30165; - pub const ID_MODE_RELATIVE_FILTER_COMBO_BOX: u32 = 30166; - pub const ID_SETTINGS_ROTATE_CHECK_BOX: u32 = 30167; - pub const ID_SETTINGS_MAKE_ABSOLUTE_CHECK_BOX: u32 = 30168; - pub const ID_MODE_BUTTON_GROUP_BOX: u32 = 30169; - pub const ID_MODE_FIRE_COMBO_BOX: u32 = 30170; - pub const ID_MODE_BUTTON_FILTER_COMBO_BOX: u32 = 30171; - pub const ID_MODE_FIRE_LINE_2_LABEL_1: u32 = 30172; - pub const ID_MODE_FIRE_LINE_2_SLIDER_CONTROL: u32 = 30173; - pub const ID_MODE_FIRE_LINE_2_EDIT_CONTROL: u32 = 30174; - pub const ID_MODE_FIRE_LINE_2_LABEL_2: u32 = 30175; - pub const ID_MODE_FIRE_LINE_3_LABEL_1: u32 = 30176; - pub const ID_MODE_FIRE_LINE_3_SLIDER_CONTROL: u32 = 30177; - pub const ID_MODE_FIRE_LINE_3_EDIT_CONTROL: u32 = 30178; - pub const ID_MODE_FIRE_LINE_3_LABEL_2: u32 = 30179; - pub const ID_MAPPING_HELP_LEFT_SUBJECT_LABEL: u32 = 30180; - pub const ID_MAPPING_HELP_LEFT_CONTENT_LABEL: u32 = 30181; - pub const IDC_MAPPING_MATCHED_INDICATOR_TEXT: u32 = 30182; - pub const ID_MAPPING_HELP_RIGHT_SUBJECT_LABEL: u32 = 30183; - pub const ID_MAPPING_HELP_RIGHT_CONTENT_LABEL: u32 = 30184; - pub const IDC_BEEP_ON_SUCCESS_CHECK_BOX: u32 = 30185; - pub const ID_MAPPING_PANEL_PREVIOUS_BUTTON: u32 = 30186; - pub const ID_MAPPING_PANEL_OK: u32 = 30187; - pub const ID_MAPPING_PANEL_NEXT_BUTTON: u32 = 30188; - pub const IDC_MAPPING_ENABLED_CHECK_BOX: u32 = 30189; - pub const ID_MAPPING_ROW_PANEL: u32 = 30207; - pub const ID_MAPPING_ROW_MAPPING_LABEL: u32 = 30191; - pub const IDC_MAPPING_ROW_ENABLED_CHECK_BOX: u32 = 30192; - pub const ID_MAPPING_ROW_EDIT_BUTTON: u32 = 30193; - pub const ID_MAPPING_ROW_DUPLICATE_BUTTON: u32 = 30194; - pub const ID_MAPPING_ROW_REMOVE_BUTTON: u32 = 30195; - pub const ID_MAPPING_ROW_LEARN_SOURCE_BUTTON: u32 = 30196; - pub const ID_MAPPING_ROW_LEARN_TARGET_BUTTON: u32 = 30197; - pub const ID_MAPPING_ROW_CONTROL_CHECK_BOX: u32 = 30198; - pub const ID_MAPPING_ROW_FEEDBACK_CHECK_BOX: u32 = 30199; - pub const ID_MAPPING_ROW_SOURCE_LABEL_TEXT: u32 = 30200; - pub const ID_MAPPING_ROW_TARGET_LABEL_TEXT: u32 = 30201; - pub const ID_MAPPING_ROW_GROUP_LABEL: u32 = 30202; - pub const IDC_MAPPING_ROW_MATCHED_INDICATOR_TEXT: u32 = 30203; - pub const ID_UP_BUTTON: u32 = 30205; - pub const ID_DOWN_BUTTON: u32 = 30206; - pub const ID_MAPPING_ROWS_PANEL: u32 = 30210; - pub const ID_DISPLAY_ALL_GROUPS_BUTTON: u32 = 30208; - pub const ID_GROUP_IS_EMPTY_TEXT: u32 = 30209; - pub const ID_MESSAGE_PANEL: u32 = 30212; - pub const ID_MESSAGE_TEXT: u32 = 30211; - pub const ID_SHARED_GROUP_MAPPING_PANEL: u32 = 30228; - pub const ID_MAPPING_NAME_LABEL: u32 = 30213; - pub const ID_MAPPING_NAME_EDIT_CONTROL: u32 = 30214; - pub const ID_MAPPING_TAGS_LABEL: u32 = 30215; - pub const ID_MAPPING_TAGS_EDIT_CONTROL: u32 = 30216; - pub const ID_MAPPING_CONTROL_ENABLED_CHECK_BOX: u32 = 30217; - pub const ID_MAPPING_FEEDBACK_ENABLED_CHECK_BOX: u32 = 30218; - pub const ID_MAPPING_ACTIVATION_TYPE_LABEL: u32 = 30219; - pub const ID_MAPPING_ACTIVATION_TYPE_COMBO_BOX: u32 = 30220; - pub const ID_MAPPING_ACTIVATION_SETTING_1_LABEL_TEXT: u32 = 30221; - pub const ID_MAPPING_ACTIVATION_SETTING_1_BUTTON: u32 = 30222; - pub const ID_MAPPING_ACTIVATION_SETTING_1_CHECK_BOX: u32 = 30223; - pub const ID_MAPPING_ACTIVATION_SETTING_2_LABEL_TEXT: u32 = 30224; - pub const ID_MAPPING_ACTIVATION_SETTING_2_BUTTON: u32 = 30225; - pub const ID_MAPPING_ACTIVATION_SETTING_2_CHECK_BOX: u32 = 30226; - pub const ID_MAPPING_ACTIVATION_EDIT_CONTROL: u32 = 30227; - pub const ID_INSTANCE_PANEL: u32 = 30229; - pub const ID_MAIN_PANEL: u32 = 30236; - pub const ID_MAIN_PANEL_STATUS_1_TEXT: u32 = 30231; - pub const ID_MAIN_PANEL_STATUS_2_TEXT: u32 = 30232; - pub const IDC_UNIT_BUTTON: u32 = 30233; - pub const IDC_EDIT_TAGS_BUTTON: u32 = 30234; - pub const ID_MAIN_PANEL_VERSION_TEXT: u32 = 30235; - pub const ID_YAML_EDITOR_PANEL: u32 = 30241; + pub const ID_SETTINGS_STEP_SIZE_GROUP: u32 = 30156; + pub const ID_SETTINGS_MIN_STEP_SIZE_LABEL_TEXT: u32 = 30157; + pub const ID_SETTINGS_MIN_STEP_SIZE_SLIDER_CONTROL: u32 = 30158; + pub const ID_SETTINGS_MIN_STEP_SIZE_EDIT_CONTROL: u32 = 30159; + pub const ID_SETTINGS_MIN_STEP_SIZE_VALUE_TEXT: u32 = 30160; + pub const ID_SETTINGS_MAX_STEP_SIZE_LABEL_TEXT: u32 = 30161; + pub const ID_SETTINGS_MAX_STEP_SIZE_SLIDER_CONTROL: u32 = 30162; + pub const ID_SETTINGS_MAX_STEP_SIZE_EDIT_CONTROL: u32 = 30163; + pub const ID_SETTINGS_MAX_STEP_SIZE_VALUE_TEXT: u32 = 30164; + pub const ID_MODE_RELATIVE_FILTER_COMBO_BOX: u32 = 30165; + pub const ID_SETTINGS_ROTATE_CHECK_BOX: u32 = 30166; + pub const ID_SETTINGS_MAKE_ABSOLUTE_CHECK_BOX: u32 = 30167; + pub const ID_MODE_BUTTON_GROUP_BOX: u32 = 30168; + pub const ID_MODE_FIRE_COMBO_BOX: u32 = 30169; + pub const ID_MODE_BUTTON_FILTER_COMBO_BOX: u32 = 30170; + pub const ID_MODE_FIRE_LINE_2_LABEL_1: u32 = 30171; + pub const ID_MODE_FIRE_LINE_2_SLIDER_CONTROL: u32 = 30172; + pub const ID_MODE_FIRE_LINE_2_EDIT_CONTROL: u32 = 30173; + pub const ID_MODE_FIRE_LINE_2_LABEL_2: u32 = 30174; + pub const ID_MODE_FIRE_LINE_3_LABEL_1: u32 = 30175; + pub const ID_MODE_FIRE_LINE_3_SLIDER_CONTROL: u32 = 30176; + pub const ID_MODE_FIRE_LINE_3_EDIT_CONTROL: u32 = 30177; + pub const ID_MODE_FIRE_LINE_3_LABEL_2: u32 = 30178; + pub const ID_MAPPING_HELP_LEFT_SUBJECT_LABEL: u32 = 30179; + pub const ID_MAPPING_HELP_LEFT_CONTENT_LABEL: u32 = 30180; + pub const IDC_MAPPING_MATCHED_INDICATOR_TEXT: u32 = 30181; + pub const ID_MAPPING_HELP_RIGHT_SUBJECT_LABEL: u32 = 30182; + pub const ID_MAPPING_HELP_RIGHT_CONTENT_LABEL: u32 = 30183; + pub const IDC_BEEP_ON_SUCCESS_CHECK_BOX: u32 = 30184; + pub const ID_MAPPING_PANEL_PREVIOUS_BUTTON: u32 = 30185; + pub const ID_MAPPING_PANEL_OK: u32 = 30186; + pub const ID_MAPPING_PANEL_NEXT_BUTTON: u32 = 30187; + pub const IDC_MAPPING_ENABLED_CHECK_BOX: u32 = 30188; + pub const ID_MAPPING_ROW_PANEL: u32 = 30206; + pub const ID_MAPPING_ROW_MAPPING_LABEL: u32 = 30190; + pub const IDC_MAPPING_ROW_ENABLED_CHECK_BOX: u32 = 30191; + pub const ID_MAPPING_ROW_EDIT_BUTTON: u32 = 30192; + pub const ID_MAPPING_ROW_DUPLICATE_BUTTON: u32 = 30193; + pub const ID_MAPPING_ROW_REMOVE_BUTTON: u32 = 30194; + pub const ID_MAPPING_ROW_LEARN_SOURCE_BUTTON: u32 = 30195; + pub const ID_MAPPING_ROW_LEARN_TARGET_BUTTON: u32 = 30196; + pub const ID_MAPPING_ROW_CONTROL_CHECK_BOX: u32 = 30197; + pub const ID_MAPPING_ROW_FEEDBACK_CHECK_BOX: u32 = 30198; + pub const ID_MAPPING_ROW_SOURCE_LABEL_TEXT: u32 = 30199; + pub const ID_MAPPING_ROW_TARGET_LABEL_TEXT: u32 = 30200; + pub const ID_MAPPING_ROW_GROUP_LABEL: u32 = 30201; + pub const IDC_MAPPING_ROW_MATCHED_INDICATOR_TEXT: u32 = 30202; + pub const ID_UP_BUTTON: u32 = 30204; + pub const ID_DOWN_BUTTON: u32 = 30205; + pub const ID_MAPPING_ROWS_PANEL: u32 = 30209; + pub const ID_DISPLAY_ALL_GROUPS_BUTTON: u32 = 30207; + pub const ID_GROUP_IS_EMPTY_TEXT: u32 = 30208; + pub const ID_MESSAGE_PANEL: u32 = 30211; + pub const ID_MESSAGE_TEXT: u32 = 30210; + pub const ID_SHARED_GROUP_MAPPING_PANEL: u32 = 30227; + pub const ID_MAPPING_NAME_LABEL: u32 = 30212; + pub const ID_MAPPING_NAME_EDIT_CONTROL: u32 = 30213; + pub const ID_MAPPING_TAGS_LABEL: u32 = 30214; + pub const ID_MAPPING_TAGS_EDIT_CONTROL: u32 = 30215; + pub const ID_MAPPING_CONTROL_ENABLED_CHECK_BOX: u32 = 30216; + pub const ID_MAPPING_FEEDBACK_ENABLED_CHECK_BOX: u32 = 30217; + pub const ID_MAPPING_ACTIVATION_TYPE_LABEL: u32 = 30218; + pub const ID_MAPPING_ACTIVATION_TYPE_COMBO_BOX: u32 = 30219; + pub const ID_MAPPING_ACTIVATION_SETTING_1_LABEL_TEXT: u32 = 30220; + pub const ID_MAPPING_ACTIVATION_SETTING_1_BUTTON: u32 = 30221; + pub const ID_MAPPING_ACTIVATION_SETTING_1_CHECK_BOX: u32 = 30222; + pub const ID_MAPPING_ACTIVATION_SETTING_2_LABEL_TEXT: u32 = 30223; + pub const ID_MAPPING_ACTIVATION_SETTING_2_BUTTON: u32 = 30224; + pub const ID_MAPPING_ACTIVATION_SETTING_2_CHECK_BOX: u32 = 30225; + pub const ID_MAPPING_ACTIVATION_EDIT_CONTROL: u32 = 30226; + pub const ID_INSTANCE_PANEL: u32 = 30228; + pub const ID_MAIN_PANEL: u32 = 30235; + pub const ID_MAIN_PANEL_STATUS_1_TEXT: u32 = 30230; + pub const ID_MAIN_PANEL_STATUS_2_TEXT: u32 = 30231; + pub const IDC_UNIT_BUTTON: u32 = 30232; + pub const IDC_EDIT_TAGS_BUTTON: u32 = 30233; + pub const ID_MAIN_PANEL_VERSION_TEXT: u32 = 30234; + pub const ID_YAML_EDITOR_PANEL: u32 = 30240; #[allow(dead_code)] - pub const ID_YAML_TEXT_EDITOR_BUTTON: u32 = 30237; - pub const ID_YAML_EDIT_CONTROL: u32 = 30238; - pub const ID_YAML_HELP_BUTTON: u32 = 30239; - pub const ID_YAML_EDIT_INFO_TEXT: u32 = 30240; + pub const ID_YAML_TEXT_EDITOR_BUTTON: u32 = 30236; + pub const ID_YAML_EDIT_CONTROL: u32 = 30237; + pub const ID_YAML_HELP_BUTTON: u32 = 30238; + pub const ID_YAML_EDIT_INFO_TEXT: u32 = 30239; #[allow(dead_code)] - pub const ID_EMPTY_PANEL: u32 = 30242; + pub const ID_EMPTY_PANEL: u32 = 30241; #[allow(dead_code)] - pub const ID_SETUP_PANEL: u32 = 30243; - pub const ID_SETUP_INTRO_TEXT_1: u32 = 30244; - pub const ID_SETUP_INTRO_TEXT_2: u32 = 30245; - pub const ID_SETUP_ADD_PLAYTIME_TOOLBAR_BUTTON: u32 = 30246; - pub const ID_SETUP_TIP_TEXT: u32 = 30247; - pub const ID_SETUP_PANEL_OK: u32 = 30248; - pub const ID_COLOR_PANEL: u32 = 30249; - pub const ID_HIDDEN_PANEL: u32 = 30250; + pub const ID_SETUP_PANEL: u32 = 30242; + pub const ID_SETUP_INTRO_TEXT_1: u32 = 30243; + pub const ID_SETUP_INTRO_TEXT_2: u32 = 30244; + pub const ID_SETUP_ADD_PLAYTIME_TOOLBAR_BUTTON: u32 = 30245; + pub const ID_SETUP_TIP_TEXT: u32 = 30246; + pub const ID_SETUP_PANEL_OK: u32 = 30247; + pub const ID_COLOR_PANEL: u32 = 30248; + pub const ID_HIDDEN_PANEL: u32 = 30249; } diff --git a/main/src/infrastructure/ui/companion_app_presenter.rs b/main/src/infrastructure/ui/companion_app_presenter.rs index 2a4ec7166..0abfe5ed0 100644 --- a/main/src/infrastructure/ui/companion_app_presenter.rs +++ b/main/src/infrastructure/ui/companion_app_presenter.rs @@ -93,11 +93,7 @@ impl CompanionAppPresenter { self.session.upgrade().expect("session gone") } - fn generate_qr_code( - &self, - content: &str, - target_file: &Path, - ) -> Result<(u32, u32), Box> { + fn generate_qr_code(&self, content: &str, target_file: &Path) -> anyhow::Result<(u32, u32)> { let code = QrCode::new(content)?; type P = image::LumaA; let min_size = 250; diff --git a/main/src/infrastructure/ui/header_panel.rs b/main/src/infrastructure/ui/header_panel.rs index f19fb98f5..2ab413f35 100644 --- a/main/src/infrastructure/ui/header_panel.rs +++ b/main/src/infrastructure/ui/header_panel.rs @@ -198,6 +198,9 @@ impl HeaderPanel { One(WantsKeyboardInput) => { self.invalidate_control_input_button(); } + One(StreamDeckDeviceId) => { + self.invalidate_control_input_button(); + } One(InCompartment(compartment, One(InGroup(_, _)))) if *compartment == self.active_compartment() => { @@ -1719,9 +1722,9 @@ impl HeaderPanel { } fn invalidate_control_input_button(&self) { - let session = self.session(); - let session = session.borrow(); - let mut text = match session.control_input() { + let unit_model = self.session(); + let unit = unit_model.borrow(); + let mut text = match unit.control_input() { ControlInput::Midi(midi_control_input) => match midi_control_input { MidiControlInput::FxInput => CONTROL_INPUT_MIDI_FX_INPUT_LABEL.to_string(), MidiControlInput::Device(dev_id) => { @@ -1731,9 +1734,12 @@ impl HeaderPanel { }, ControlInput::Osc(osc_device_id) => get_osc_dev_list_label(&osc_device_id, false), }; - if session.wants_keyboard_input() { + if unit.wants_keyboard_input() { text.insert_str(0, "[Keyboard] + "); } + if unit.stream_deck_device_id().is_some() { + text.insert_str(0, "[Stream Deck] + "); + } self.view .require_control(root::ID_CONTROL_INPUT_BUTTON) .set_text(text); @@ -1786,13 +1792,21 @@ impl HeaderPanel { } fn pick_control_input(&self) { - let (current_control_input, current_wants_keyboard_input) = { + let (current_control_input, current_wants_keyboard_input, current_stream_deck_dev_id) = { let session = self.session(); let session = session.borrow(); - (session.control_input(), session.wants_keyboard_input()) + ( + session.control_input(), + session.wants_keyboard_input(), + session.stream_deck_device_id(), + ) }; let result = self.view.require_window().open_popup_menu( - menus::control_input_menu(current_control_input, current_wants_keyboard_input), + menus::control_input_menu( + current_control_input, + current_wants_keyboard_input, + current_stream_deck_dev_id, + ), Window::cursor_pos(), ); if let Some(action) = result { @@ -1806,14 +1820,22 @@ impl HeaderPanel { self.execute_osc_dev_management_action(action); } ControlInputMenuAction::ToggleWantsKeyboardInput => { - let weak_session = self.session.clone(); - if let Some(session) = weak_session.upgrade() { + if let Some(session) = self.session.clone().upgrade() { let mut session = session.borrow_mut(); let current_value = session.wants_keyboard_input(); session.change_with_notification( SessionCommand::SetWantsKeyboardInput(!current_value), None, - weak_session, + self.session.clone(), + ) + } + } + ControlInputMenuAction::SelectStreamDeckDevice(dev) => { + if let Some(session) = self.session.clone().upgrade() { + session.borrow_mut().change_with_notification( + SessionCommand::SetStreamDeckDevice(dev), + None, + self.session.clone(), ) } } diff --git a/main/src/infrastructure/ui/mapping_panel.rs b/main/src/infrastructure/ui/mapping_panel.rs index 6db1439ce..35c74b610 100644 --- a/main/src/infrastructure/ui/mapping_panel.rs +++ b/main/src/infrastructure/ui/mapping_panel.rs @@ -47,9 +47,10 @@ use crate::application::{ MappingRefModel, MappingSnapshotTypeForLoad, MappingSnapshotTypeForTake, MidiSourceType, ModeCommand, ModeModel, ModeProp, RealearnAutomationMode, RealearnTrackArea, ReaperSourceType, SessionProp, SharedMapping, SharedUnitModel, SourceCategory, SourceCommand, SourceModel, - SourceProp, TargetCategory, TargetCommand, TargetModel, TargetModelFormatVeryShort, - TargetModelWithContext, TargetProp, TargetUnit, TrackRouteSelectorType, UnitModel, - VirtualFxParameterType, VirtualFxType, VirtualTrackType, WeakUnitModel, KEY_UNDEFINED_LABEL, + SourceProp, StreamDeckButtonBackgroundType, StreamDeckButtonForegroundType, TargetCategory, + TargetCommand, TargetModel, TargetModelFormatVeryShort, TargetModelWithContext, TargetProp, + TargetUnit, TrackRouteSelectorType, UnitModel, VirtualFxParameterType, VirtualFxType, + VirtualTrackType, WeakUnitModel, KEY_UNDEFINED_LABEL, }; use crate::base::{notification, when, Prop}; use crate::domain::ui_util::{ @@ -282,7 +283,7 @@ impl MappingPanel { } P::Channel => { view.invalidate_source_control_visibilities(); - view.invalidate_source_line_3_combo_box_1(); + view.invalidate_source_line_3_combo_box(); view.invalidate_source_line_5_combo_box(); } P::MidiMessageNumber => { @@ -304,7 +305,7 @@ impl MappingPanel { view.invalidate_mode_controls(); } P::MidiClockTransportMessage => { - view.invalidate_source_line_3_combo_box_2(); + view.invalidate_source_line_3_combo_box(); } P::IsRegistered => { view.invalidate_source_line_4_check_box(); @@ -329,7 +330,7 @@ impl MappingPanel { view.invalidate_source_line_7_edit_control(initiator); } P::ParameterIndex => { - view.invalidate_source_line_3_combo_box_1() + view.invalidate_source_line_3_combo_box() } P::MidiScriptKind => { view.invalidate_source_line_3(initiator); @@ -347,6 +348,13 @@ impl MappingPanel { P::Keystroke => { view.invalidate_source_line_3(initiator); } + P::ButtonIndex => { view.invalidate_source_line_3(initiator); } + P::ButtonBackgroundType => { + view.invalidate_source_line_4(initiator); + } + P::ButtonForegroundType => { + view.invalidate_source_line_5(initiator); + } } } } @@ -1994,15 +2002,29 @@ impl<'a> MutableMappingPanel<'a> { SourceCommand::SetOscArgIsRelative(checked), )); } - Reaper | Virtual | Never | Keyboard => {} + Reaper | Virtual | Never | Keyboard | StreamDeck => {} }; } - fn handle_source_line_3_combo_box_1_change(&mut self) { + fn handle_source_line_3_combo_box_change(&mut self) { let b = self.view.require_control(root::ID_SOURCE_CHANNEL_COMBO_BOX); use SourceCategory::*; match self.mapping.source_model.category() { Midi => match self.mapping.source_model.midi_source_type() { + MidiSourceType::ClockTransport => { + let i = b.selected_combo_box_item_index(); + let msg_type = i.try_into().expect("invalid MTC message type"); + self.change_mapping(MappingCommand::ChangeSource( + SourceCommand::SetMidiClockTransportMessage(msg_type), + )); + } + MidiSourceType::Display => { + let i = b.selected_combo_box_item_index(); + let display_type = i.try_into().expect("invalid display type"); + self.change_mapping(MappingCommand::ChangeSource( + SourceCommand::SetDisplayType(display_type), + )); + } MidiSourceType::Script => { let i = b.selected_combo_box_item_index(); let kind = i.try_into().expect("invalid source script kind"); @@ -2030,8 +2052,14 @@ impl<'a> MutableMappingPanel<'a> { ), )); } - _ => b.hide(), + _ => {} }, + StreamDeck => { + let index = b.selected_combo_box_item_index() as u32; + self.change_mapping(MappingCommand::ChangeSource(SourceCommand::SetButtonIndex( + index, + ))); + } _ => {} }; } @@ -2096,11 +2124,18 @@ impl<'a> MutableMappingPanel<'a> { SourceCommand::SetOscArgTypeTag(tag), )); } + StreamDeck => { + let i = b.selected_combo_box_item_index(); + let background_type = i.try_into().expect("invalid background type"); + self.change_mapping(MappingCommand::ChangeSource( + SourceCommand::SetButtonBackgroundType(background_type), + )); + } _ => {} } } - fn handle_source_line_5_combo_box_2_change(&mut self) { + fn handle_source_line_5_combo_box_change(&mut self) { let b = self .view .require_control(root::ID_SOURCE_CHARACTER_COMBO_BOX); @@ -2128,6 +2163,13 @@ impl<'a> MutableMappingPanel<'a> { _ => {} } } + StreamDeck => { + let i = b.selected_combo_box_item_index(); + let foreground_type = i.try_into().expect("invalid background type"); + self.change_mapping(MappingCommand::ChangeSource( + SourceCommand::SetButtonForegroundType(foreground_type), + )); + } _ => {} } } @@ -2172,33 +2214,6 @@ impl<'a> MutableMappingPanel<'a> { }; } - fn handle_source_line_3_combo_box_2_change(&mut self) { - let b = self - .view - .require_control(root::ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX); - use SourceCategory::*; - match self.mapping.source_model.category() { - Midi => match self.mapping.source_model.midi_source_type() { - MidiSourceType::ClockTransport => { - let i = b.selected_combo_box_item_index(); - let msg_type = i.try_into().expect("invalid MTC message type"); - self.change_mapping(MappingCommand::ChangeSource( - SourceCommand::SetMidiClockTransportMessage(msg_type), - )); - } - MidiSourceType::Display => { - let i = b.selected_combo_box_item_index(); - let display_type = i.try_into().expect("invalid display type"); - self.change_mapping(MappingCommand::ChangeSource( - SourceCommand::SetDisplayType(display_type), - )); - } - _ => {} - }, - _ => {} - } - } - fn handle_source_line_4_edit_control_change(&mut self) { let edit_control_id = root::ID_SOURCE_NUMBER_EDIT_CONTROL; let c = self.view.require_control(edit_control_id); @@ -2221,7 +2236,7 @@ impl<'a> MutableMappingPanel<'a> { Some(edit_control_id), ); } - Reaper | Never | Keyboard | Osc => {} + Reaper | Never | Keyboard | Osc | StreamDeck => {} }; } @@ -2264,7 +2279,7 @@ impl<'a> MutableMappingPanel<'a> { } _ => {} }, - Midi | Virtual | Never | Keyboard => {} + Midi | Virtual | Never | Keyboard | StreamDeck => {} } } } @@ -4202,8 +4217,7 @@ impl<'a> ImmutableMappingPanel<'a> { fn invalidate_source_line_3(&self, initiator: Option) { self.invalidate_source_line_3_label_1(); self.invalidate_source_line_3_label_2(); - self.invalidate_source_line_3_combo_box_1(); - self.invalidate_source_line_3_combo_box_2(); + self.invalidate_source_line_3_combo_box(); self.invalidate_source_line_3_edit_control(initiator); } @@ -4222,6 +4236,7 @@ impl<'a> ImmutableMappingPanel<'a> { _ => None, }, Keyboard => Some("Key"), + StreamDeck => Some("Button"), _ => None, }; self.view @@ -4244,11 +4259,23 @@ impl<'a> ImmutableMappingPanel<'a> { .set_text_or_hide(text); } - fn invalidate_source_line_3_combo_box_1(&self) { + fn invalidate_source_line_3_combo_box(&self) { let b = self.view.require_control(root::ID_SOURCE_CHANNEL_COMBO_BOX); use SourceCategory::*; match self.source.category() { Midi => match self.source.midi_source_type() { + MidiSourceType::ClockTransport => { + b.show(); + b.fill_combo_box_indexed(MidiClockTransportMessage::iter()); + b.select_combo_box_item_by_index( + self.source.midi_clock_transport_message().into(), + ); + } + MidiSourceType::Display => { + b.show(); + b.fill_combo_box_indexed(DisplayType::iter()); + b.select_combo_box_item_by_index(self.source.display_type().into()); + } MidiSourceType::Script => { b.fill_combo_box_indexed(MidiScriptKind::iter()); b.show(); @@ -4283,6 +4310,11 @@ impl<'a> ImmutableMappingPanel<'a> { } _ => b.hide(), }, + StreamDeck => { + b.fill_combo_box_indexed((0..32).map(|i| (i + 1).to_string())); + b.select_combo_box_item_by_index(self.source.button_index() as _); + b.show(); + } _ => { b.hide(); } @@ -4376,6 +4408,7 @@ impl<'a> ImmutableMappingPanel<'a> { } Virtual => Some("ID"), Osc => Some("Argument"), + StreamDeck => Some("Background"), _ => None, }; self.view @@ -4457,6 +4490,11 @@ impl<'a> ImmutableMappingPanel<'a> { let tag = self.source.osc_arg_type_tag(); invalidate_with_osc_arg_type_tag(b, tag); } + StreamDeck => { + b.fill_combo_box_indexed(StreamDeckButtonBackgroundType::iter()); + b.show(); + b.select_combo_box_item_by_index(self.source.button_background_type().into()); + } _ => { b.hide(); } @@ -4630,6 +4668,7 @@ impl<'a> ImmutableMappingPanel<'a> { } } Osc if self.source.supports_osc_arg_value_range() => Some("Range"), + StreamDeck => Some("Foreground"), _ => None, }; self.view @@ -4676,6 +4715,11 @@ impl<'a> ImmutableMappingPanel<'a> { } } } + StreamDeck => { + b.fill_combo_box_indexed(StreamDeckButtonForegroundType::iter()); + b.show(); + b.select_combo_box_item_by_index(self.source.button_foreground_type().into()); + } _ => { b.hide(); } @@ -4702,35 +4746,6 @@ impl<'a> ImmutableMappingPanel<'a> { .set_text_or_hide(text); } - fn invalidate_source_line_3_combo_box_2(&self) { - let b = self - .view - .require_control(root::ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX); - use SourceCategory::*; - match self.source.category() { - Midi => match self.source.midi_source_type() { - MidiSourceType::ClockTransport => { - b.show(); - b.fill_combo_box_indexed(MidiClockTransportMessage::iter()); - b.select_combo_box_item_by_index( - self.source.midi_clock_transport_message().into(), - ); - } - MidiSourceType::Display => { - b.show(); - b.fill_combo_box_indexed(DisplayType::iter()); - b.select_combo_box_item_by_index(self.source.display_type().into()); - } - _ => { - b.hide(); - } - }, - _ => { - b.hide(); - } - } - } - fn invalidate_target_controls(&self, initiator: Option) { self.invalidate_target_category_combo_box(); self.invalidate_target_type_button(); @@ -6903,7 +6918,7 @@ impl<'a> ImmutableMappingPanel<'a> { Midi => b.fill_combo_box_indexed(MidiSourceType::iter()), Reaper => b.fill_combo_box_indexed(ReaperSourceType::iter()), Virtual => b.fill_combo_box_indexed(VirtualControlElementCharacter::iter()), - Osc | Never | Keyboard => {} + Osc | Never | Keyboard | StreamDeck => {} }; } @@ -7156,7 +7171,7 @@ impl View for MappingPanel { self.write(|p| p.handle_source_line_2_combo_box_change()) } root::ID_SOURCE_CHANNEL_COMBO_BOX => { - self.write(|p| p.handle_source_line_3_combo_box_1_change()) + self.write(|p| p.handle_source_line_3_combo_box_change()) } root::ID_SOURCE_NUMBER_COMBO_BOX => { self.write(|p| p.handle_source_line_4_combo_box_2_change()) @@ -7165,10 +7180,7 @@ impl View for MappingPanel { self.write(|p| p.handle_source_line_4_combo_box_1_change()) } root::ID_SOURCE_CHARACTER_COMBO_BOX => { - self.write(|p| p.handle_source_line_5_combo_box_2_change()) - } - root::ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX => { - self.write(|p| p.handle_source_line_3_combo_box_2_change()) + self.write(|p| p.handle_source_line_5_combo_box_change()) } // Mode root::ID_SETTINGS_MODE_COMBO_BOX => self.write(|p| p.update_mode_type()), @@ -8296,7 +8308,6 @@ impl Section { | ID_SOURCE_CHANNEL_LABEL | ID_SOURCE_CHANNEL_COMBO_BOX | ID_SOURCE_LINE_3_EDIT_CONTROL - | ID_SOURCE_MIDI_CLOCK_TRANSPORT_MESSAGE_TYPE_COMBOX_BOX | ID_SOURCE_NOTE_OR_CC_NUMBER_LABEL_TEXT | ID_SOURCE_RPN_CHECK_BOX | ID_SOURCE_LINE_4_COMBO_BOX_1 diff --git a/main/src/infrastructure/ui/menus.rs b/main/src/infrastructure/ui/menus.rs index 217442cf3..cb20c34cd 100644 --- a/main/src/infrastructure/ui/menus.rs +++ b/main/src/infrastructure/ui/menus.rs @@ -1,8 +1,9 @@ use crate::application::{UnitModel, WeakUnitModel}; use crate::domain::{ - compartment_param_index_iter, CompartmentKind, CompartmentParamIndex, CompartmentParams, - ControlInput, FeedbackOutput, MappingId, MidiControlInput, MidiDestination, OscDeviceId, - ReaperTargetType, TargetSection, + compartment_param_index_iter, probe_stream_deck_devices, CompartmentKind, + CompartmentParamIndex, CompartmentParams, ControlInput, FeedbackOutput, MappingId, + MidiControlInput, MidiDestination, OscDeviceId, ProbedStreamDeckDevice, ReaperTargetType, + StreamDeckDeviceId, TargetSection, }; use crate::infrastructure::data::{CommonPresetInfo, OscDevice}; use crate::infrastructure::plugin::{ActionSection, BackboneShell, ACTION_DEFS}; @@ -28,6 +29,7 @@ pub enum ControlInputMenuAction { SelectControlInput(ControlInput), ManageOsc(OscDeviceManagementAction), ToggleWantsKeyboardInput, + SelectStreamDeckDevice(Option), } pub fn midi_device_input_menu( @@ -35,6 +37,10 @@ pub fn midi_device_input_menu( none_label: &str, ) -> Menu> { let (open_midi_devs, closed_midi_devs) = get_open_and_closed_midi_input_devs(); + let unavailable_midi_input_devices = closed_midi_devs + .into_iter() + .map(|dev| build_midi_input_dev_menu_item(dev, current_value)) + .collect(); let entries = iter::once(item_with_opts( none_label, ItemOpts { @@ -48,13 +54,10 @@ pub fn midi_device_input_menu( .into_iter() .map(|dev| build_midi_input_dev_menu_item(dev, current_value)), ) - .chain(iter::once(menu( + .chain([create_category_menu( "Unavailable MIDI input devices", - closed_midi_devs - .into_iter() - .map(|dev| build_midi_input_dev_menu_item(dev, current_value)) - .collect(), - ))); + unavailable_midi_input_devices, + )]); anonymous_menu(entries.collect()) } @@ -67,7 +70,8 @@ fn get_open_and_closed_midi_input_devs() -> (Vec, Vec, ) -> Menu { let fx_input = ControlInput::Midi(MidiControlInput::FxInput); let (open_midi_devs, closed_midi_devs) = get_open_and_closed_midi_input_devs(); @@ -78,6 +82,24 @@ pub fn control_input_menu( .devices() .partition(|dev| dev.input_status().is_connected()) }; + let stream_deck_devices = probe_stream_deck_devices().unwrap_or_default(); + let unavailable_midi_input_devs = closed_midi_devs + .into_iter() + .map(|dev| build_control_input_midi_input_dev_menu_item(dev, current_value)) + .collect(); + let unavailable_osc_devs = closed_osc_devs + .into_iter() + .map(|dev| build_osc_input_dev_menu_item(dev, current_value)) + .collect(); + let unavailable_stream_deck_devs = stream_deck_devices + .iter() + .filter(|d| !d.available) + .map(|dev| build_stream_deck_dev_menu_item(dev, current_stream_deck_dev_id)) + .collect(); + let available_stream_deck_devs = stream_deck_devices + .iter() + .filter(|d| d.available) + .map(|dev| build_stream_deck_dev_menu_item(dev, current_stream_deck_dev_id)); let entries = [item_with_opts( CONTROL_INPUT_MIDI_FX_INPUT_LABEL, ItemOpts { @@ -93,12 +115,9 @@ pub fn control_input_menu( .map(|dev| build_control_input_midi_input_dev_menu_item(dev, current_value)), ) .chain([ - menu( + create_category_menu( "Unavailable MIDI input devices", - closed_midi_devs - .into_iter() - .map(|dev| build_control_input_midi_input_dev_menu_item(dev, current_value)) - .collect(), + unavailable_midi_input_devs, ), separator(), ]) @@ -108,27 +127,34 @@ pub fn control_input_menu( .map(|dev| build_osc_input_dev_menu_item(dev, current_value)), ) .chain([ - menu( - "Unavailable OSC devices", - closed_osc_devs - .into_iter() - .map(|dev| build_osc_input_dev_menu_item(dev, current_value)) - .collect(), - ), + create_category_menu("Unavailable OSC devices", unavailable_osc_devs), menu( "Manage OSC devices", osc_device_management_menu_entries(ControlInputMenuAction::ManageOsc), ), separator(), - item_with_opts( - CONTROL_INPUT_KEYBOARD_LABEL, - ItemOpts { - enabled: true, - checked: wants_keyboard_input, - }, - ControlInputMenuAction::ToggleWantsKeyboardInput, - ), - ]); + ]) + .chain([item_with_opts( + "Stream Deck: ", + ItemOpts { + enabled: true, + checked: current_stream_deck_dev_id.is_none(), + }, + ControlInputMenuAction::SelectStreamDeckDevice(None), + )]) + .chain(available_stream_deck_devs) + .chain([ + create_category_menu("Unavailable Stream Decks", unavailable_stream_deck_devs), + separator(), + ]) + .chain([item_with_opts( + CONTROL_INPUT_KEYBOARD_LABEL, + ItemOpts { + enabled: true, + checked: current_wants_keyboard_input, + }, + ControlInputMenuAction::ToggleWantsKeyboardInput, + )]); let mut entries: Vec<_> = entries.collect(); if Reaper::get() .medium_reaper() @@ -170,6 +196,14 @@ pub fn feedback_output_menu( .devices() .partition(|dev| dev.input_status().is_connected()) }; + let unsvailable_midi_devs = closed_midi_devs + .into_iter() + .map(|dev| build_midi_output_dev_menu_item(dev, current_value)) + .collect(); + let unavailable_osc_devs = closed_osc_devs + .into_iter() + .map(|dev| build_osc_output_dev_menu_item(dev, current_value)) + .collect(); let entries = iter::once(item_with_opts( FEEDBACK_OUTPUT_NONE_LABEL, ItemOpts { @@ -192,31 +226,23 @@ pub fn feedback_output_menu( .into_iter() .map(|dev| build_midi_output_dev_menu_item(dev, current_value)), ) - .chain(iter::once(menu( - "Unavailable MIDI output devices", - closed_midi_devs - .into_iter() - .map(|dev| build_midi_output_dev_menu_item(dev, current_value)) - .collect(), - ))) - .chain(iter::once(separator())) + .chain([ + create_category_menu("Unavailable MIDI output devices", unsvailable_midi_devs), + separator(), + ]) .chain( open_osc_devs .into_iter() .map(|dev| build_osc_output_dev_menu_item(dev, current_value)), ) - .chain(iter::once(menu( - "Unavailable OSC devices", - closed_osc_devs - .into_iter() - .map(|dev| build_osc_output_dev_menu_item(dev, current_value)) - .collect(), - ))) - .chain(iter::once(menu( - "Manage OSC devices", - osc_device_management_menu_entries(FeedbackOutputMenuAction::ManageOsc), - ))) - .chain(iter::once(separator())); + .chain([ + create_category_menu("Unavailable OSC devices", unavailable_osc_devs), + menu( + "Manage OSC devices", + osc_device_management_menu_entries(FeedbackOutputMenuAction::ManageOsc), + ), + separator(), + ]); anonymous_menu(entries.collect()) } @@ -299,6 +325,20 @@ fn build_osc_output_dev_menu_item( ) } +fn build_stream_deck_dev_menu_item( + dev: &ProbedStreamDeckDevice, + current: Option, +) -> Entry { + item_with_opts( + format!("Stream Deck: {}", dev.dev.name), + ItemOpts { + enabled: true, + checked: current == Some(dev.dev.id), + }, + ControlInputMenuAction::SelectStreamDeckDevice(Some(dev.dev.id)), + ) +} + pub fn get_osc_device_list_label(dev: &OscDevice, is_output: bool) -> String { format!("OSC: {}", dev.get_list_label(is_output)) } @@ -700,28 +740,7 @@ where .map(move |(category, mut infos)| { infos.sort_by_key(|info| &info.meta_data.name); let entries = categorizer.create_sub_entries(infos.into_iter(), build_id, current_id); - let mut contains_current_entry = false; - const CURRENT_CATEGORY_SUFFIX: &str = " *"; - let entries = entries - .into_iter() - .inspect(|e| { - let is_current = match e { - Entry::Menu(m) => m.text.ends_with(CURRENT_CATEGORY_SUFFIX), - Entry::Item(i) => i.opts.checked, - _ => false, - }; - if is_current { - contains_current_entry = true; - } - }) - .collect(); - let category_suffix = if contains_current_entry { - CURRENT_CATEGORY_SUFFIX - } else { - "" - }; - let category_label = format!("{category}{category_suffix}"); - menu(category_label, entries) + create_category_menu(category, entries) }) .collect() } @@ -934,3 +953,19 @@ impl<'a> Categorizer<'a> for ManufacturerCategorizer { build_compartment_preset_menu_entries_internal(infos.into_iter(), build_id, current_id) } } + +fn create_category_menu(category: impl Display, entries: Vec>) -> Entry { + const CURRENT_CATEGORY_SUFFIX: &str = " *"; + let contains_current_entry = entries.iter().any(|e| match e { + Entry::Menu(m) => m.text.ends_with(CURRENT_CATEGORY_SUFFIX), + Entry::Item(i) => i.opts.checked, + _ => false, + }); + let category_suffix = if contains_current_entry { + CURRENT_CATEGORY_SUFFIX + } else { + "" + }; + let category_label = format!("{category}{category_suffix}"); + menu(category_label, entries) +}