feat: implement rendering encoder feedback layouts to the touchstrip of the Stream Deck Plus (#356)

Co-authored-by: nekename <63245705+nekename@users.noreply.github.com>
This commit is contained in:
Craig McLureandnekename authored and nekename committed 2026-06-24 12:17:13 +01:00
1 parent 2eaebe60d5
commit a53dbe58d0
16 files changed
+897 -6

No files matched your search

+355 -4
View File
@@ -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"
+2
View File
@@ -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"
+59 -1
View File
@@ -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<RwLock<HashMap<String, AsyncStreamDeck>>> = 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<DynamicImage, anyhow::Error> {
// 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,6 +86,15 @@ 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" {
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,
@@ -45,6 +102,7 @@ pub async fn update_image(context: &crate::shared::Context, image: Option<&str>)
&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 {
+4
View File
@@ -58,6 +58,8 @@ pub enum InboundEventType {
LogMessage(PayloadEvent<misc::LogMessageEvent>),
SetTitle(ContextAndPayloadEvent<states::SetTitlePayload>),
SetImage(ContextAndPayloadEvent<states::SetImagePayload>),
SetFeedbackLayout(ContextAndPayloadEvent<states::SetFeedbackLayoutPayload>),
SetFeedback(ContextAndPayloadEvent<serde_json::Value>),
SetState(ContextAndPayloadEvent<states::SetStatePayload>),
ShowAlert(ContextEvent),
ShowOk(ContextEvent),
@@ -126,6 +128,8 @@ pub async fn process_incoming_message(data: Result<Message, Error>, 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,
+150
View File
@@ -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<SetTitlePayload>) -> Result<(), anyhow::Error> {
let mut locks = acquire_locks_mut().await;
@@ -101,6 +110,147 @@ pub async fn set_image(mut event: ContextAndPayloadEvent<SetImagePayload>) -> 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<Value>) -> 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<SetFeedbackLayoutPayload>) -> 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<SetStatePayload>) -> Result<(), anyhow::Error> {
let mut locks = acquire_locks_mut().await;
+41 -1
View File
@@ -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<Spa
action.property_inspector = path.join(property_inspector).to_string_lossy().to_string();
}
if let Some(encoder) = &mut action.encoder {
if !encoder.icon.is_empty() {
let encoder_icon_path = path.join(encoder.icon.clone());
encoder.icon = convert_icon(encoder_icon_path.to_str().unwrap().to_owned());
}
if !encoder.background.is_empty() {
let encoder_background_path = path.join(encoder.background.clone());
encoder.background = convert_icon(encoder_background_path.to_str().unwrap().to_owned());
}
if !encoder.layout.is_empty() {
// Internal layouts start with $; custom layouts don't
if !encoder.layout.starts_with("$") {
let layout_path = path.join(&encoder.layout).canonicalize()?;
if layout_path.starts_with(&path) {
encoder.layout = layout_path.to_string_lossy().to_string();
} else {
warn!("Encoder layout path is outside plugin directory: {}", encoder.layout);
encoder.layout = String::new();
}
}
if !encoder.layout.is_empty() {
// Parse out the initial layout
let initial = load_encoder_layout(&encoder.layout);
match initial {
Ok(value) => {
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);
+82
View File
@@ -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<String>,
#[serde(default, alias = "Encoder")]
pub encoder: Option<Encoder>,
#[serde(alias = "States")]
pub states: Vec<ActionState>,
}
/// 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<serde_json::Value> {
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 {
+12
View File
@@ -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);
+2
View File
@@ -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[];
};
+15
View File
@@ -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;
};
+25
View File
@@ -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
}
]
}
+25
View File
@@ -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"
}
]
}
+33
View File
@@ -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
}
]
}
+35
View File
@@ -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"
}
]
}
+39
View File
@@ -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
}
]
}
+18
View File
@@ -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]
}
]
}