diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7772bdd..4b0efd0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "active-win-pos-rs" version = "0.10.1" @@ -109,6 +125,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -922,6 +944,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1099,6 +1130,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.10" @@ -1442,6 +1479,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1540,6 +1586,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1559,6 +1611,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2332,7 +2406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2453,10 +2527,17 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", + "png 0.18.1", "zune-core", "zune-jpeg", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -2679,6 +2760,18 @@ dependencies = [ "selectors", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2762,6 +2855,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -2960,7 +3059,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3424,6 +3523,7 @@ dependencies = [ "serde-inline-default", "serde_json", "serde_with", + "streamdeck-strip-render", "sysinfo", "tauri", "tauri-build", @@ -3534,6 +3634,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -3842,6 +3951,12 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3897,6 +4012,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3911,6 +4039,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4467,6 +4604,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + [[package]] name = "rfd" version = "0.16.0" @@ -4491,6 +4642,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -4534,6 +4694,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -4689,6 +4864,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.22" @@ -5092,6 +5285,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5110,6 +5312,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -5180,6 +5391,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streamdeck-strip-render" +version = "0.1.0" +source = "git+https://github.com/FrostyCoolSlug/streamdeck-strip-render?rev=948c4653#948c4653ec310539e081fbdec334463a72766d89" +dependencies = [ + "ab_glyph", + "anyhow", + "base64 0.22.1", + "image", + "log", + "resvg", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -5226,6 +5462,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher 1.0.2", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -5483,7 +5729,7 @@ dependencies = [ "ico", "json-patch 3.0.1", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5888,6 +6134,32 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -6186,7 +6458,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -6198,6 +6470,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -6285,18 +6566,54 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "untrusted" version = "0.9.0" @@ -6334,6 +6651,34 @@ dependencies = [ "url", ] +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher 1.0.2", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -7376,6 +7721,12 @@ dependencies = [ "quick-xml 0.30.0", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4a3ed8..0fe1447 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ tiny_http = "0.12" elgato-streamdeck = { version = "0.12", default-features = false, features = ["async"] } hidapi = "2.6" image = { version = "0.25", default-features = false, features = ["bmp", "jpeg"] } +streamdeck-strip-render = { git = "https://github.com/FrostyCoolSlug/streamdeck-strip-render", rev = "948c4653" } + # Smaller utility libraries dashmap = { version = "6.1", features = ["serde"] } futures = "0.3" diff --git a/src-tauri/src/elgato.rs b/src-tauri/src/elgato.rs index e37bec6..42f492e 100644 --- a/src-tauri/src/elgato.rs +++ b/src-tauri/src/elgato.rs @@ -1,4 +1,6 @@ use crate::events::inbound; +use crate::shared::{ActionInstance, Encoder, config_dir}; +use crate::store::profiles::{acquire_locks, get_slot}; use std::collections::HashMap; use std::sync::Arc; @@ -10,7 +12,8 @@ use elgato_streamdeck::{ images::{ImageRect, convert_image_with_format_async}, info::Kind, }; -use image::GenericImageView as _; +use image::{DynamicImage, GenericImageView as _}; +use serde_json::Value; use tokio::sync::RwLock; static ELGATO_DEVICES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -25,6 +28,51 @@ fn extract_average_colour(img: &image::DynamicImage) -> (u8, u8, u8) { ((r_sum / count) as u8, (g_sum / count) as u8, (b_sum / count) as u8) } +fn get_encoder_image(encoder: &Encoder, instance: &ActionInstance) -> Result { + // Clone the layout so we can mutate it for rendering without persisting + let mut layout = encoder.layout_parsed.clone(); + let path = config_dir().join("plugins").join(&instance.action.plugin); + + // We need to validate whether title text and icon images are defined; if not, pull them from the state/action + if let Some(items_array) = layout.get_mut("items").and_then(Value::as_array_mut) { + // If the title is missing, provide it from the state/action + if let Some(title_item) = items_array.iter_mut().find(|item| item.get("key").and_then(Value::as_str) == Some("title")) { + let title_value = title_item.get("value").and_then(Value::as_str).unwrap_or("").trim(); + + if title_value.is_empty() { + // Try to pull the title from the state + let state_text = instance.states.get(instance.current_state as usize).and_then(|s| { + let t = s.text.trim(); + if t.is_empty() { None } else { Some(t) } + }); + + // If the state title is empty, fall back to the action name + let title = state_text.unwrap_or(instance.action.name.as_str()); + title_item["value"] = Value::String(title.to_string()); + } + } + + // If the icon is missing, provide it from the state/action + if let Some(icon_item) = items_array.iter_mut().find(|item| item.get("key").and_then(Value::as_str) == Some("icon")) { + let icon_empty = icon_item.get("value").and_then(Value::as_str).is_none_or(str::is_empty); + if icon_empty { + let icon = instance + .states + .get(instance.current_state as usize) + .map(|state| &state.image) + .filter(|image| !image.is_empty()) + .unwrap_or(&instance.action.icon); + + if !icon.is_empty() { + icon_item["value"] = Value::String(icon.clone()); + } + } + } + } + + streamdeck_strip_render::render_to_image(layout, &path, None) +} + pub async fn update_image(context: &crate::shared::Context, image: Option<&str>) -> Result<(), anyhow::Error> { if let Some(device) = ELGATO_DEVICES.read().await.get(&context.device) { let kind = device.kind(); @@ -38,13 +86,23 @@ pub async fn update_image(context: &crate::shared::Context, image: Option<&str>) let data = image.split_once(',').unwrap().1; let bytes = base64::engine::general_purpose::STANDARD.decode(data)?; if context.controller == "Encoder" { - device - .write_lcd( - (context.position as u16 * 200) + 64, - 14, - &ImageRect::from_image_async(image::load_from_memory(&bytes)?.resize(72, 72, image::imageops::FilterType::Nearest))?, - ) - .await?; + let locks = acquire_locks().await; + if let Some(instance) = get_slot(context, &locks).await? + && let Some(encoder) = &instance.action.encoder + { + let img = get_encoder_image(encoder, instance)?; + device.write_lcd(context.position as u16 * 200, 0, &ImageRect::from_image_async(img.clone())?).await?; + } else { + // If this encoder doesn't have a layout assigned to it, fall back and just render the icon. + // This shouldn't happen and is a safety fallback behaviour. + device + .write_lcd( + (context.position as u16 * 200) + 64, + 14, + &ImageRect::from_image_async(image::load_from_memory(&bytes)?.resize(72, 72, image::imageops::FilterType::Nearest))?, + ) + .await?; + } } else if context.controller == "Infobar" { let img = image::load_from_memory(&bytes)?; let Some(format) = device.kind().lcd_image_format() else { diff --git a/src-tauri/src/events/inbound/mod.rs b/src-tauri/src/events/inbound/mod.rs index d9f21ef..e984328 100644 --- a/src-tauri/src/events/inbound/mod.rs +++ b/src-tauri/src/events/inbound/mod.rs @@ -58,6 +58,8 @@ pub enum InboundEventType { LogMessage(PayloadEvent), SetTitle(ContextAndPayloadEvent), SetImage(ContextAndPayloadEvent), + SetFeedbackLayout(ContextAndPayloadEvent), + SetFeedback(ContextAndPayloadEvent), SetState(ContextAndPayloadEvent), ShowAlert(ContextEvent), ShowOk(ContextEvent), @@ -126,6 +128,8 @@ pub async fn process_incoming_message(data: Result, uuid: &str, InboundEventType::LogMessage(event) => misc::log_message(Some(uuid), event).await, InboundEventType::SetTitle(event) => states::set_title(event).await, InboundEventType::SetImage(event) => states::set_image(event).await, + InboundEventType::SetFeedbackLayout(event) => states::set_feedback_layout(event).await, + InboundEventType::SetFeedback(event) => states::set_feedback(event).await, InboundEventType::SetState(event) => states::set_state(event).await, InboundEventType::ShowAlert(event) => misc::show_alert(event).await, InboundEventType::ShowOk(event) => misc::show_ok(event).await, diff --git a/src-tauri/src/events/inbound/states.rs b/src-tauri/src/events/inbound/states.rs index 056d575..a7a2e20 100644 --- a/src-tauri/src/events/inbound/states.rs +++ b/src-tauri/src/events/inbound/states.rs @@ -1,9 +1,13 @@ use super::ContextAndPayloadEvent; use crate::events::frontend::instances::update_state; +use crate::shared::{config_dir, load_encoder_layout}; use crate::store::profiles::{acquire_locks_mut, debounce_profile_save, get_instance_mut, save_profile}; +use anyhow::bail; +use log::warn; use serde::Deserialize; +use serde_json::Value; #[derive(Deserialize)] pub struct SetTitlePayload { @@ -22,6 +26,11 @@ pub struct SetStatePayload { state: u16, } +#[derive(Debug, Deserialize)] +pub struct SetFeedbackLayoutPayload { + layout: String, +} + pub async fn set_title(event: ContextAndPayloadEvent) -> Result<(), anyhow::Error> { let mut locks = acquire_locks_mut().await; @@ -101,6 +110,147 @@ pub async fn set_image(mut event: ContextAndPayloadEvent) -> Re Ok(()) } +const COMMON_KEYS: &[&str] = &["enabled", "opacity", "background"]; +const BAR_KEYS: &[&str] = &["bar_bg_c", "bar_border_c", "bar_fill_c", "border_w", "range", "subtype", "value"]; +pub async fn set_feedback(event: ContextAndPayloadEvent) -> Result<(), anyhow::Error> { + let mut locks = acquire_locks_mut().await; + + if let Some(instance) = get_instance_mut(&event.context, &mut locks).await? + && let Some(encoder) = &mut instance.action.encoder + && let Value::Object(map) = event.payload + { + let layout = &mut encoder.layout_parsed; + + let Some(items_array) = layout.get_mut("items").and_then(Value::as_array_mut) else { + bail!("Layout has no items array"); + }; + + for (key, payload_value) in &map { + // Grab the item from the layout + let Some(item) = items_array.iter_mut().find(|item| { + matches!( + item.get("key").and_then(|v| v.as_str()), + Some(k) if k == key + ) + }) else { + warn!("setFeedback: no layout item found for key '{key}'"); + continue; + }; + + match payload_value { + // We have a direct value; find the key, and set it + Value::String(_) | Value::Number(_) => { + // Get the item type + let Some(item_type) = item.get("type").and_then(Value::as_str) else { + warn!("setFeedback: no type found for key '{key}'"); + continue; + }; + + match item_type { + "text" => { + // We need to map the value to a string + if let Value::Number(number) = payload_value { + item["value"] = Value::String(number.to_string()); + } else { + // Clone the string + item["value"] = payload_value.clone(); + } + } + + "bar" | "gbar" => { + if let Value::Number(value) = payload_value { + if key == "value" { + item["value"] = Value::Number(value.clone()); + } + } else { + warn!("setFeedback: bar/gbar expected number for key '{key}'"); + } + } + + "pixmap" => { + // Update the pixmap value; this should already be a string + item["value"] = payload_value.clone(); + } + + // Ignore anything else + _ => { + warn!("setFeedback: unknown item type '{item_type}' for key '{key}'"); + } + } + } + + // We have an object, so we need to locate and map the change + Value::Object(obj) => { + // Get the item type + let Some(item_type) = item.get("type").and_then(Value::as_str) else { + warn!("setFeedback: missing or invalid 'type' field in item: {:?}", item); + continue; + }; + + // Get the valid keys for this item type + let type_keys: Vec<&str> = match item_type { + "text" => vec!["value", "color", "alignment", "font", "text-overflow"], + "pixmap" => vec!["value"], + "bar" => BAR_KEYS.to_vec(), + "gbar" => BAR_KEYS.iter().copied().chain(["bar_h"]).collect(), + unknown => { + warn!("setFeedback: unknown item type '{unknown}' for key '{key}'"); + continue; + } + }; + + // Add the common keys + let valid_keys: Vec<&str> = COMMON_KEYS.iter().copied().chain(type_keys).collect(); + + // Iterate over the values in the object + for (field, field_value) in obj { + if valid_keys.contains(&field.as_str()) { + item[field] = field_value.clone() + } + } + } + + _ => { + warn!("setFeedback: key '{key}' has unexpected payload type, ignoring"); + } + } + } + + update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await?; + } + + Ok(()) +} + +pub async fn set_feedback_layout(event: ContextAndPayloadEvent) -> Result<(), anyhow::Error> { + let mut locks = acquire_locks_mut().await; + if let Some(instance) = get_instance_mut(&event.context, &mut locks).await? + && let Some(encoder) = &mut instance.action.encoder + { + // We need to replace the existing parsed layout with the new one + encoder.layout = event.payload.layout.clone(); + + // Make sure the layout is a full path to the JSON file + if !encoder.layout.starts_with("$") { + let path = config_dir().join("plugins").join(&instance.action.plugin); + + let layout_path = path.join(&encoder.layout).canonicalize()?; + if layout_path.starts_with(&path) { + encoder.layout = layout_path.to_string_lossy().to_string(); + } else { + // SetFeedbackLayout is sending a path outside our plugin; abort + bail!("Encoder layout path is outside plugin directory: {}", encoder.layout); + } + } + + encoder.layout_parsed = load_encoder_layout(&encoder.layout)?; + + // Trigger a state update; should cause a redraw + update_state(crate::APP_HANDLE.get().unwrap(), instance.context.clone(), &mut locks).await?; + } + Ok(()) +} + pub async fn set_state(event: ContextAndPayloadEvent) -> Result<(), anyhow::Error> { let mut locks = acquire_locks_mut().await; diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 9362c68..d79768e 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -4,7 +4,7 @@ mod webserver; use crate::APP_HANDLE; use crate::built_info::TARGET; -use crate::shared::{CATEGORIES, Category, config_dir, convert_icon, is_flatpak, log_dir}; +use crate::shared::{CATEGORIES, Category, config_dir, convert_icon, is_flatpak, load_encoder_layout, log_dir}; use std::collections::HashMap; use std::process::{Child, Command, Stdio}; @@ -91,6 +91,46 @@ pub async fn initialise_plugin(path: path::PathBuf, spawner_tx: mpsc::Sender { + encoder.layout_parsed = value; + } + Err(e) => { + warn!("Failed to load initial encoder layout: {}", e); + encoder.layout_parsed = serde_json::Value::Null; + } + } + } + } + } + for state in &mut action.states { if state.image == "actionDefaultImage" { state.image.clone_from(&action.icon); diff --git a/src-tauri/src/shared.rs b/src-tauri/src/shared.rs index 3f24763..ed64f90 100644 --- a/src-tauri/src/shared.rs +++ b/src-tauri/src/shared.rs @@ -6,6 +6,7 @@ use std::sync::LazyLock; use serde::{Deserialize, Deserializer, Serialize, de::Visitor}; use serde_inline_default::serde_inline_default; +use anyhow::{Result, bail}; use dashmap::DashMap; use tauri::Manager; use tokio::sync::RwLock; @@ -218,10 +219,91 @@ pub struct Action { #[serde(alias = "Controllers")] pub controllers: Vec, + #[serde(default, alias = "Encoder")] + pub encoder: Option, + #[serde(alias = "States")] pub states: Vec, } +/// An encoder, deserialised from the plugin manifest. +#[serde_inline_default] +#[derive(Clone, Serialize, Deserialize)] +pub struct Encoder { + #[serde_inline_default(String::new())] + #[serde(alias = "Icon")] + pub icon: String, + + #[serde_inline_default(String::new())] + #[serde(alias = "StackColor")] + pub stack_color: String, + + #[serde(default, alias = "TriggerDescription")] + pub trigger_description: TriggerDescription, + + #[serde_inline_default(String::new())] + pub background: String, + + #[serde_inline_default(String::new())] + pub layout: String, + + // Note: this is not a real manifest property; it is only used internally. + #[serde_inline_default(serde_json::Value::Null)] + pub layout_parsed: serde_json::Value, +} + +// This can be called from plugins/mod.rs; it can also be called from a setFeedbackLayout request +pub fn load_encoder_layout(layout: &str) -> Result { + match layout { + "$A0" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/A0.json"))?), + "$A1" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/A1.json"))?), + "$B1" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/B1.json"))?), + "$B2" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/B2.json"))?), + "$C1" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/C1.json"))?), + "$X1" => Ok(serde_json::from_str(include_str!("../../static/encoder_layouts/X1.json"))?), + + path_str => { + if path_str.is_empty() { + bail!("Encoder layout path is blank, ignoring"); + } + + // This doesn't match an existing built-in layout + if path_str.starts_with('$') { + bail!("Invalid encoder layout type: {}", path_str); + } + + let path = Path::new(path_str); + if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")) { + bail!("Invalid encoder layout type: {}", path_str); + } + + let content = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) + } + } +} + +/// Descriptions of touchstrip interaction response behaviours, to be shown to the user in the PI +#[serde_inline_default] +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct TriggerDescription { + #[serde_inline_default(String::new())] + #[serde(alias = "LongTouch")] + pub long_touch: String, + + #[serde_inline_default(String::new())] + #[serde(alias = "Push")] + pub push: String, + + #[serde_inline_default(String::new())] + #[serde(alias = "Rotate")] + pub rotate: String, + + #[serde_inline_default(String::new())] + #[serde(alias = "Touch")] + pub touch: String, +} + /// Location metadata of a slot. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Context { diff --git a/src-tauri/src/store/profiles.rs b/src-tauri/src/store/profiles.rs index bf03e6c..13527a7 100644 --- a/src-tauri/src/store/profiles.rs +++ b/src-tauri/src/store/profiles.rs @@ -64,6 +64,18 @@ impl ProfileStores { } } } + + // We need to populate instances from a profile without encoders with them + for slot in store.value.sliders.iter_mut() { + if let Some(instance) = slot + && instance.action.encoder.is_none() + && let Some(action) = actions.iter().find(|a| a.uuid == *instance.action.uuid) + && action.encoder.is_some() + { + instance.action.encoder = action.encoder.clone(); + } + } + store.save()?; self.stores.insert(canonical_id.clone(), store); diff --git a/src/lib/Action.ts b/src/lib/Action.ts index d9f6795..03b6092 100644 --- a/src/lib/Action.ts +++ b/src/lib/Action.ts @@ -1,4 +1,5 @@ import type { ActionState } from "./ActionState.ts"; +import type { Encoder } from "./Encoder.ts"; export type Action = { name: string; @@ -10,5 +11,6 @@ export type Action = { supported_in_multi_actions: boolean; property_inspector: string; controllers: string[]; + encoder?: Encoder | null; states: ActionState[]; }; diff --git a/src/lib/Encoder.ts b/src/lib/Encoder.ts new file mode 100644 index 0000000..7cf52fc --- /dev/null +++ b/src/lib/Encoder.ts @@ -0,0 +1,15 @@ +export type Encoder = { + icon: string; + stack_color: string; + trigger_description: TriggerDescription; + background: string; + layout: string; + layout_parsed: object; +}; + +export type TriggerDescription = { + long_touch: string; + push: string; + rotate: string; + touch: string; +}; diff --git a/static/encoder_layouts/A0.json b/static/encoder_layouts/A0.json new file mode 100644 index 0000000..87eb8cb --- /dev/null +++ b/static/encoder_layouts/A0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$A0", + "items": [ + { + "key": "full-canvas", + "type": "pixmap", + "rect": [0, 0, 200, 100] + }, + { + "key": "title", + "type": "text", + "rect": [16, 10, 168, 24], + "zOrder": 1, + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "canvas", + "type": "pixmap", + "rect": [16, 34, 168, 54], + "zOrder": 1 + } + ] +} diff --git a/static/encoder_layouts/A1.json b/static/encoder_layouts/A1.json new file mode 100644 index 0000000..04c5534 --- /dev/null +++ b/static/encoder_layouts/A1.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$A1", + "items": [ + { + "key": "title", + "type": "text", + "rect": [16, 10, 136, 24], + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "icon", + "type": "pixmap", + "rect": [16, 40, 48, 48] + }, + { + "key": "value", + "type": "text", + "rect": [76, 40, 108, 32], + "font": { "size": 24, "weight": 600 }, + "alignment": "right" + } + ] +} diff --git a/static/encoder_layouts/B1.json b/static/encoder_layouts/B1.json new file mode 100644 index 0000000..df09190 --- /dev/null +++ b/static/encoder_layouts/B1.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$B1", + "items": [ + { + "key": "title", + "type": "text", + "rect": [16, 10, 136, 24], + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "icon", + "type": "pixmap", + "rect": [16, 40, 48, 48] + }, + { + "key": "value", + "type": "text", + "rect": [76, 40, 108, 32], + "font": { "size": 24, "weight": 600 }, + "alignment": "right" + }, + { + "key": "indicator", + "type": "bar", + "rect": [76, 74, 108, 12], + "value": 20, + "subtype": 4, + "border_w": 0 + } + ] +} diff --git a/static/encoder_layouts/B2.json b/static/encoder_layouts/B2.json new file mode 100644 index 0000000..f3e78d2 --- /dev/null +++ b/static/encoder_layouts/B2.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$B2", + "items": [ + { + "key": "title", + "type": "text", + "rect": [16, 10, 136, 24], + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "icon", + "type": "pixmap", + "rect": [16, 40, 48, 48] + }, + { + "key": "value", + "type": "text", + "rect": [76, 40, 108, 32], + "font": { "size": 24, "weight": 600 }, + "alignment": "right" + }, + { + "key": "indicator", + "type": "gbar", + "rect": [76, 74, 108, 20], + "value": 20, + "subtype": 4, + "bar_h": 12, + "border_w": 0, + "bar_bg_c": "0:#ff0000,0.33:#a6d4ec,0.66:#f4b675,1:#00ff00" + } + ] +} diff --git a/static/encoder_layouts/C1.json b/static/encoder_layouts/C1.json new file mode 100644 index 0000000..29d8c68 --- /dev/null +++ b/static/encoder_layouts/C1.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$C1", + "items": [ + { + "key": "title", + "type": "text", + "rect": [16, 10, 136, 24], + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "icon1", + "type": "pixmap", + "rect": [16, 40, 24, 24] + }, + { + "key": "icon2", + "type": "pixmap", + "rect": [16, 68, 24, 24] + }, + { + "key": "indicator1", + "type": "bar", + "rect": [48, 46, 136, 12], + "value": 70, + "subtype": 4, + "border_w": 0 + }, + { + "key": "indicator2", + "type": "bar", + "rect": [48, 74, 136, 12], + "value": 40, + "subtype": 4, + "border_w": 0 + } + ] +} diff --git a/static/encoder_layouts/X1.json b/static/encoder_layouts/X1.json new file mode 100644 index 0000000..29caf6e --- /dev/null +++ b/static/encoder_layouts/X1.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schemas.elgato.com/streamdeck/plugins/layout.json", + "id": "$X1", + "items": [ + { + "key": "title", + "type": "text", + "rect": [16, 10, 136, 24], + "font": { "size": 16, "weight": 600 }, + "alignment": "left" + }, + { + "key": "icon", + "type": "pixmap", + "rect": [76, 40, 48, 48] + } + ] +}