commit
91
Cargo.lock
generated
|
@ -76,7 +76,7 @@ version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
|
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -86,7 +86,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
|
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"windows-sys",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -373,15 +373,15 @@ dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"flume",
|
"flume",
|
||||||
"humantime-serde",
|
"humantime-serde",
|
||||||
|
"is_executable",
|
||||||
"log",
|
"log",
|
||||||
"loupedeck_serial",
|
"loupedeck_serial",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pa-volume-interface",
|
|
||||||
"parse-display",
|
|
||||||
"regex",
|
"regex",
|
||||||
"resvg",
|
"resvg",
|
||||||
"rgb",
|
"rgb",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_regex",
|
"serde_regex",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
@ -398,8 +398,9 @@ dependencies = [
|
||||||
"deckster_shared",
|
"deckster_shared",
|
||||||
"either",
|
"either",
|
||||||
"im",
|
"im",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -410,11 +411,11 @@ dependencies = [
|
||||||
"enum-map",
|
"enum-map",
|
||||||
"enum-ordinalize",
|
"enum-ordinalize",
|
||||||
"im",
|
"im",
|
||||||
|
"parse-display",
|
||||||
"rgb",
|
"rgb",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -525,9 +526,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_logger"
|
name = "env_logger"
|
||||||
version = "0.11.0"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9eeb342678d785662fd2514be38c459bb925f02b68dd2a3e0f21d7ef82d979dd"
|
checksum = "05e7cf40684ae96ade6232ed84582f40ce0a66efcd43a5117aef610534f8e0b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
|
@ -807,6 +808,15 @@ dependencies = [
|
||||||
"mach2",
|
"mach2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_executable"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.10"
|
version = "1.0.10"
|
||||||
|
@ -975,6 +985,17 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nanorand"
|
name = "nanorand"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -1047,7 +1068,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
|
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pa-volume-interface"
|
name = "pa_volume"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"color-eyre",
|
||||||
|
"deckster_mode",
|
||||||
|
"env_logger",
|
||||||
|
"im",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"pa_volume_interface",
|
||||||
|
"parse-display",
|
||||||
|
"serde",
|
||||||
|
"serde_regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pa_volume_interface"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flume",
|
"flume",
|
||||||
|
@ -1361,18 +1399,18 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.195"
|
version = "1.0.196"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
|
checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.195"
|
version = "1.0.196"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
|
checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
@ -1381,9 +1419,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.111"
|
version = "1.0.113"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4"
|
checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"ryu",
|
"ryu",
|
||||||
|
@ -1466,6 +1504,15 @@ dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
|
@ -1717,10 +1764,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
|
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2085,6 +2137,15 @@ dependencies = [
|
||||||
"windows-targets 0.52.0",
|
"windows-targets 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|
42
Cargo.toml
|
@ -1,10 +1,42 @@
|
||||||
|
[package]
|
||||||
|
name = "deckster"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bytes = "1.5.0"
|
||||||
|
clap = { version = "4.4.12", features = ["derive"] }
|
||||||
|
color-eyre = "0.6.2"
|
||||||
|
cosmic-text = "0.10.0"
|
||||||
|
derive_more = "0.99.17"
|
||||||
|
encode_unicode = "1.0.0"
|
||||||
|
enum-map = "3.0.0-beta.2"
|
||||||
|
enum-ordinalize = "4.3.0"
|
||||||
|
env_logger = "0.11.0"
|
||||||
|
flume = "0.11.0"
|
||||||
|
humantime-serde = "1.1.1"
|
||||||
|
log = "0.4.20"
|
||||||
|
loupedeck_serial = { path = "./crates/loupedeck_serial" }
|
||||||
|
deckster_shared = { path = "./crates/deckster_shared" }
|
||||||
|
regex = "1.10.2"
|
||||||
|
resvg = "0.37.0"
|
||||||
|
rgb = "0.8.37"
|
||||||
|
serde = { version = "1.0.193", features = ["derive", "rc"] }
|
||||||
|
serde_json = "1.0.113"
|
||||||
|
serde_regex = "1.1.0"
|
||||||
|
serde_with = "3.4.0"
|
||||||
|
thiserror = "1.0.52"
|
||||||
|
tiny-skia = "0.11.3"
|
||||||
|
tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt", "rt-multi-thread", "sync", "process", "io-util"] }
|
||||||
|
toml = "0.8.8"
|
||||||
|
walkdir = "2.4.0"
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
is_executable = "1.0.1"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"deckster",
|
"crates/*",
|
||||||
"deckster_mode",
|
"handlers/*"
|
||||||
"deckster_shared",
|
|
||||||
"loupedeck_serial",
|
|
||||||
"pa-volume-interface"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
|
@ -7,5 +7,6 @@ edition = "2021"
|
||||||
deckster_shared = { path = "../deckster_shared" }
|
deckster_shared = { path = "../deckster_shared" }
|
||||||
thiserror = "1.0.56"
|
thiserror = "1.0.56"
|
||||||
im = "15.1.0"
|
im = "15.1.0"
|
||||||
toml = "0.8.8"
|
|
||||||
either = "1.9.0"
|
either = "1.9.0"
|
||||||
|
serde = { version = "1.0.196", default-features = false }
|
||||||
|
serde_json = "1.0.113"
|
88
crates/deckster_mode/src/lib.rs
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
use std::any::TypeId;
|
||||||
|
use std::io;
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
use either::Either;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use deckster_shared::handler_communication::HandlerInitializationResultMessage;
|
||||||
|
pub use deckster_shared::handler_communication::{HandlerEvent, HandlerInitializationError, InitialHandlerMessage};
|
||||||
|
pub use deckster_shared::path::*;
|
||||||
|
pub use deckster_shared::state::{KeyStyleByStateMap, KnobStyleByStateMap};
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RunError {
|
||||||
|
#[error("A stdin line could not be read.")]
|
||||||
|
LineIo(#[from] io::Error),
|
||||||
|
|
||||||
|
#[error("A stdin line could not be deserialized: {description}")]
|
||||||
|
LineDeserialization { line: String, description: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait DecksterHandler {
|
||||||
|
fn handle(&mut self, event: HandlerEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run<
|
||||||
|
KeyConfig: Clone + DeserializeOwned + 'static,
|
||||||
|
KnobConfig: Clone + DeserializeOwned + 'static,
|
||||||
|
H: DecksterHandler,
|
||||||
|
I: FnOnce(InitialHandlerMessage<KeyConfig, KnobConfig>) -> Result<H, HandlerInitializationError>,
|
||||||
|
>(
|
||||||
|
init_handler: I,
|
||||||
|
) -> Result<(), RunError> {
|
||||||
|
let mut handler: Either<H, I> = Either::Right(init_handler);
|
||||||
|
|
||||||
|
let supports_keys = TypeId::of::<KeyConfig>() != TypeId::of::<()>();
|
||||||
|
let supports_knobs = TypeId::of::<KnobConfig>() != TypeId::of::<()>();
|
||||||
|
|
||||||
|
let handle = io::stdin().lock();
|
||||||
|
for line in handle.lines() {
|
||||||
|
let line = line?;
|
||||||
|
|
||||||
|
match handler {
|
||||||
|
Either::Left(mut h) => {
|
||||||
|
let event: HandlerEvent = serde_json::from_str(&line).map_err(|e| RunError::LineDeserialization {
|
||||||
|
line,
|
||||||
|
description: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
h.handle(event);
|
||||||
|
handler = Either::Left(h);
|
||||||
|
}
|
||||||
|
Either::Right(init_handler) => {
|
||||||
|
let initial_message = serde_json::from_str::<InitialHandlerMessage<KeyConfig, KnobConfig>>(&line);
|
||||||
|
|
||||||
|
match initial_message {
|
||||||
|
Ok(initial_message) => match init_handler(initial_message) {
|
||||||
|
Ok(h) => {
|
||||||
|
println!("{}", serde_json::to_string(&HandlerInitializationResultMessage::Ready).unwrap());
|
||||||
|
handler = Either::Left(h)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("{}", serde_json::to_string(&HandlerInitializationResultMessage::Error { error }).unwrap());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&HandlerInitializationResultMessage::Error {
|
||||||
|
error: HandlerInitializationError::InvalidConfig {
|
||||||
|
supports_keys,
|
||||||
|
supports_knobs,
|
||||||
|
message: err.to_string().into_boxed_str(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -12,4 +12,4 @@ rgb = "0.8.37"
|
||||||
enum-ordinalize = "4.3.0"
|
enum-ordinalize = "4.3.0"
|
||||||
enum-map = "3.0.0-beta.2"
|
enum-map = "3.0.0-beta.2"
|
||||||
im = { version = "15.1.0", features = ["serde"] }
|
im = { version = "15.1.0", features = ["serde"] }
|
||||||
toml = { version = "0.8.8", default-features = false }
|
parse-display = "0.8.2"
|
|
@ -1,4 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::path::{KeyPath, KnobPath};
|
use crate::path::{KeyPath, KnobPath};
|
||||||
use crate::style::{KeyStyle, KnobStyle};
|
use crate::style::{KeyStyle, KnobStyle};
|
||||||
|
@ -50,6 +51,27 @@ pub enum HandlerCommand {
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||||
pub struct InitialHandlerMessage<KeyConfig: Clone, KnobConfig: Clone> {
|
pub struct InitialHandlerMessage<KeyConfig: Clone, KnobConfig: Clone> {
|
||||||
key_configs: im::HashMap<KeyPath, (String, KeyConfig)>,
|
pub key_configs: im::HashMap<KeyPath, (Box<str>, KeyConfig)>,
|
||||||
knob_configs: im::HashMap<KnobPath, (String, KnobConfig)>,
|
pub knob_configs: im::HashMap<KnobPath, (Box<str>, KnobConfig)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum HandlerInitializationResultMessage {
|
||||||
|
Ready,
|
||||||
|
Error { error: HandlerInitializationError },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Error)]
|
||||||
|
pub enum HandlerInitializationError {
|
||||||
|
#[error("The provided mode string is invalid: {message}")]
|
||||||
|
InvalidModeString { message: Box<str> },
|
||||||
|
#[error("The provided handler config is invalid: {message}")]
|
||||||
|
InvalidConfig {
|
||||||
|
supports_keys: bool,
|
||||||
|
supports_knobs: bool,
|
||||||
|
message: Box<str>,
|
||||||
|
},
|
||||||
|
#[error("{message}")]
|
||||||
|
Other { message: Box<str> },
|
||||||
}
|
}
|
43
crates/deckster_shared/src/path.rs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
use enum_map::Enum;
|
||||||
|
use enum_ordinalize::Ordinalize;
|
||||||
|
use parse_display::{Display, FromStr};
|
||||||
|
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||||
|
/// One-based coordinates of a specific virtual (not physical) key.
|
||||||
|
#[display("{x}x{y}")]
|
||||||
|
pub struct KeyPosition {
|
||||||
|
pub x: u16,
|
||||||
|
pub y: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||||
|
#[display("{page_id}/{position}")]
|
||||||
|
pub struct KeyPath {
|
||||||
|
pub page_id: String,
|
||||||
|
pub position: KeyPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Enum, Ordinalize, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||||
|
#[display(style = "kebab-case")]
|
||||||
|
pub enum KnobPosition {
|
||||||
|
LeftTop,
|
||||||
|
LeftMiddle,
|
||||||
|
LeftBottom,
|
||||||
|
RightTop,
|
||||||
|
RightMiddle,
|
||||||
|
RightBottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KnobPosition {
|
||||||
|
pub fn is_left(&self) -> bool {
|
||||||
|
matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||||
|
#[display("{page_id}/{position}")]
|
||||||
|
pub struct KnobPath {
|
||||||
|
pub page_id: String,
|
||||||
|
pub position: KnobPosition,
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "pa-volume-interface"
|
name = "pa_volume_interface"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "deckster"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bytes = "1.5.0"
|
|
||||||
clap = { version = "4.4.12", features = ["derive"] }
|
|
||||||
color-eyre = "0.6.2"
|
|
||||||
cosmic-text = "0.10.0"
|
|
||||||
derive_more = "0.99.17"
|
|
||||||
encode_unicode = "1.0.0"
|
|
||||||
enum-map = "3.0.0-beta.2"
|
|
||||||
enum-ordinalize = "4.3.0"
|
|
||||||
env_logger = "0.11.0"
|
|
||||||
flume = "0.11.0"
|
|
||||||
humantime-serde = "1.1.1"
|
|
||||||
log = "0.4.20"
|
|
||||||
loupedeck_serial = { path = "../loupedeck_serial" }
|
|
||||||
pa-volume-interface = { path = "../pa-volume-interface" }
|
|
||||||
deckster_shared = { path = "../deckster_shared" }
|
|
||||||
regex = "1.10.2"
|
|
||||||
resvg = "0.37.0"
|
|
||||||
rgb = "0.8.37"
|
|
||||||
serde = { version = "1.0.193", features = ["derive", "rc"] }
|
|
||||||
serde_regex = "1.1.0"
|
|
||||||
serde_with = "3.4.0"
|
|
||||||
thiserror = "1.0.52"
|
|
||||||
tiny-skia = "0.11.3"
|
|
||||||
tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt", "rt-multi-thread", "sync"]}
|
|
||||||
toml = "0.8.8"
|
|
||||||
walkdir = "2.4.0"
|
|
||||||
once_cell = "1.19.0"
|
|
||||||
parse-display = "0.8.2"
|
|
|
@ -1,263 +0,0 @@
|
||||||
use std::collections::hash_map::Entry;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use color_eyre::eyre::{eyre, ContextCompat, WrapErr};
|
|
||||||
use color_eyre::Result;
|
|
||||||
use resvg::usvg::tiny_skia_path::IntSize;
|
|
||||||
use resvg::usvg::{TextRendering, TreeParsing, TreeTextToPath};
|
|
||||||
use tiny_skia::{BlendMode, FilterQuality, Pixmap, PixmapPaint, Transform};
|
|
||||||
|
|
||||||
use deckster_shared::icon_descriptor::{IconDescriptor, IconDescriptorSource};
|
|
||||||
use deckster_shared::image_filter::{ImageFilter, ImageFilterDestructive};
|
|
||||||
use deckster_shared::state::{KeyStyleByStateMap, KnobStyleByStateMap};
|
|
||||||
|
|
||||||
use crate::model::config::{Config, IconFormat, IconPack};
|
|
||||||
|
|
||||||
mod destructive_filter;
|
|
||||||
|
|
||||||
pub type LoadedIconsMap = HashMap<(IconDescriptorSource, ImageFilterDestructive), LoadedIcon>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct LoadedIcon {
|
|
||||||
pub pixmap: Pixmap,
|
|
||||||
pub pre_scale: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_used_icon_descriptors(config: &Config) -> HashSet<IconDescriptor> {
|
|
||||||
let mut result: HashSet<IconDescriptor> = HashSet::new();
|
|
||||||
|
|
||||||
fn insert_all_from_key_style_by_state_map<T>(result: &mut HashSet<IconDescriptor>, map: &KeyStyleByStateMap<T>) {
|
|
||||||
map.values().for_each(|v| {
|
|
||||||
if let Some(icon) = &v.icon {
|
|
||||||
result.insert(icon.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_all_from_knob_style_by_state_map<T>(result: &mut HashSet<IconDescriptor>, map: &KnobStyleByStateMap<T>) {
|
|
||||||
map.values().for_each(|v| {
|
|
||||||
if let Some(icon) = &v.icon {
|
|
||||||
result.insert(icon.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for page in config.key_pages_by_id.values() {
|
|
||||||
for key in page.keys.values() {
|
|
||||||
if let Some(d) = &key.base_style.icon {
|
|
||||||
result.insert(d.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &key.mode.playerctl__button {
|
|
||||||
insert_all_from_key_style_by_state_map(&mut result, &c.style);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &key.mode.playerctl__shuffle {
|
|
||||||
insert_all_from_key_style_by_state_map(&mut result, &c.style);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &key.mode.playerctl__loop {
|
|
||||||
insert_all_from_key_style_by_state_map(&mut result, &c.style);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &key.mode.home_assistant__button {
|
|
||||||
insert_all_from_key_style_by_state_map(&mut result, &c.style);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &key.mode.home_assistant__switch {
|
|
||||||
insert_all_from_key_style_by_state_map(&mut result, &c.style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for page in config.knob_pages_by_id.values() {
|
|
||||||
for knob in page.knobs.values() {
|
|
||||||
if let Some(d) = &knob.base_style.icon {
|
|
||||||
result.insert(d.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &knob.mode.audio_volume {
|
|
||||||
insert_all_from_knob_style_by_state_map(&mut result, &c.style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_icons(
|
|
||||||
config_directory: &Path,
|
|
||||||
icon_packs_by_id: &HashMap<String, IconPack>,
|
|
||||||
descriptors: HashSet<IconDescriptor>,
|
|
||||||
dpi: f32,
|
|
||||||
) -> Result<LoadedIconsMap> {
|
|
||||||
let mut highest_scale_by_source: HashMap<IconDescriptorSource, f32> = HashMap::new();
|
|
||||||
|
|
||||||
for d in &descriptors {
|
|
||||||
let mut scale = d.filter.transform.scale;
|
|
||||||
if let IconDescriptorSource::IconPack { pack_id, .. } = &d.source {
|
|
||||||
let pack = &icon_packs_by_id[pack_id];
|
|
||||||
if let Some(filter) = &pack.global_filter {
|
|
||||||
scale *= filter.transform.scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(v) = highest_scale_by_source.get_mut(&d.source) {
|
|
||||||
if *v < scale {
|
|
||||||
*v = scale;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highest_scale_by_source.insert(d.source.clone(), scale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut unfiltered_pixmap_and_scale_by_source: HashMap<IconDescriptorSource, (Pixmap, f32)> = HashMap::new();
|
|
||||||
let mut icons: LoadedIconsMap = HashMap::new();
|
|
||||||
let mut fonts_db = resvg::usvg::fontdb::Database::new();
|
|
||||||
fonts_db.load_system_fonts();
|
|
||||||
|
|
||||||
for descriptor in descriptors {
|
|
||||||
let icon_pack = if let IconDescriptorSource::IconPack { pack_id, .. } = &descriptor.source {
|
|
||||||
Some(icon_packs_by_id.get(pack_id).wrap_err_with(|| format!("Unknown icon pack: @{}", pack_id))?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter = if let Some(global_filter) = icon_pack.and_then(|p| p.global_filter.as_ref()) {
|
|
||||||
descriptor.filter.destructive.merge_over(&global_filter.destructive)
|
|
||||||
} else {
|
|
||||||
descriptor.filter.destructive
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = (descriptor.source, filter);
|
|
||||||
|
|
||||||
if icons.contains_key(&id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (original_image, original_image_scale) = match unfiltered_pixmap_and_scale_by_source.entry(id.0.clone()) {
|
|
||||||
Entry::Occupied(o) => o.into_mut(),
|
|
||||||
Entry::Vacant(v) => v.insert(read_image_and_get_scale(
|
|
||||||
config_directory,
|
|
||||||
dpi,
|
|
||||||
&fonts_db,
|
|
||||||
&id.0,
|
|
||||||
icon_pack,
|
|
||||||
highest_scale_by_source[&id.0],
|
|
||||||
)?),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pixmap = destructive_filter::apply(original_image, &id.1)?;
|
|
||||||
|
|
||||||
icons.insert(
|
|
||||||
id,
|
|
||||||
LoadedIcon {
|
|
||||||
pixmap,
|
|
||||||
pre_scale: *original_image_scale,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(icons)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_image_and_get_scale(
|
|
||||||
config_directory: &Path,
|
|
||||||
dpi: f32,
|
|
||||||
fonts_db: &resvg::usvg::fontdb::Database,
|
|
||||||
source: &IconDescriptorSource,
|
|
||||||
icon_pack: Option<&IconPack>,
|
|
||||||
highest_scale: f32,
|
|
||||||
) -> Result<(Pixmap, f32)> {
|
|
||||||
let path = match source {
|
|
||||||
IconDescriptorSource::Path(path) => path.clone(),
|
|
||||||
IconDescriptorSource::IconPack { icon_id, .. } => {
|
|
||||||
let icon_pack = icon_pack.unwrap();
|
|
||||||
let extension = match icon_pack.format {
|
|
||||||
IconFormat::Png => "png",
|
|
||||||
IconFormat::Svg => "svg",
|
|
||||||
};
|
|
||||||
|
|
||||||
config_directory.join(&icon_pack.path).join(icon_id.to_owned() + "." + extension)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(match path.extension() {
|
|
||||||
None => return Err(eyre!("Invalid icon path: {:?}", path)),
|
|
||||||
Some(extension) => match extension.to_string_lossy().as_ref() {
|
|
||||||
"png" => (
|
|
||||||
Pixmap::load_png(&path).wrap_err_with(|| format!("Failed to open or decode the PNG file at {}", path.to_string_lossy()))?,
|
|
||||||
1.0,
|
|
||||||
),
|
|
||||||
"svg" => (
|
|
||||||
read_image_from_svg(&path, dpi, fonts_db, highest_scale)
|
|
||||||
.wrap_err_with(|| format!("Failed to open or decode the SVG file at {}", path.to_string_lossy()))?,
|
|
||||||
highest_scale,
|
|
||||||
),
|
|
||||||
extension => return Err(eyre!("Invalid file extension, only *.png and *.svg are allowed: {}", extension)),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_image_from_svg(path: &Path, dpi: f32, font_db: &resvg::usvg::fontdb::Database, scale: f32) -> Result<Pixmap> {
|
|
||||||
let raw_data = std::fs::read(path)?;
|
|
||||||
|
|
||||||
let tree = {
|
|
||||||
let mut tree = resvg::usvg::Tree::from_data(
|
|
||||||
&raw_data,
|
|
||||||
&resvg::usvg::Options {
|
|
||||||
dpi,
|
|
||||||
font_family: "Inter".to_owned(),
|
|
||||||
font_size: 11.0,
|
|
||||||
text_rendering: TextRendering::OptimizeLegibility,
|
|
||||||
..resvg::usvg::Options::default()
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
tree.convert_text(font_db);
|
|
||||||
|
|
||||||
resvg::Tree::from_usvg(&tree)
|
|
||||||
};
|
|
||||||
|
|
||||||
let size = tree.size.to_int_size();
|
|
||||||
let mut pixmap = Pixmap::new((size.width() as f32 * scale).ceil() as u32, (size.height() as f32 * scale).ceil() as u32).unwrap();
|
|
||||||
|
|
||||||
tree.render(Transform::from_scale(scale, scale), &mut pixmap.as_mut());
|
|
||||||
|
|
||||||
Ok(pixmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_icon_in(pixmap: &mut Pixmap, global_icon_filter_by_pack_id: &HashMap<String, ImageFilter>, loaded_icons: &LoadedIconsMap, icon: &IconDescriptor) {
|
|
||||||
let filter = if let Some(global_filter) = icon.source.pack_id().and_then(|i| global_icon_filter_by_pack_id.get(i)) {
|
|
||||||
icon.filter.merge_over(global_filter)
|
|
||||||
} else {
|
|
||||||
icon.filter.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let loaded_icon = &loaded_icons[&(icon.source.clone(), filter.destructive)];
|
|
||||||
|
|
||||||
let scale = filter.transform.scale / loaded_icon.pre_scale;
|
|
||||||
|
|
||||||
let scaled_size = IntSize::from_wh(loaded_icon.pixmap.width(), loaded_icon.pixmap.height())
|
|
||||||
.unwrap()
|
|
||||||
.scale_by(scale)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
pixmap.draw_pixmap(
|
|
||||||
(((pixmap.width() as i32 - scaled_size.width() as i32) / 2) as f32 / scale).round() as i32,
|
|
||||||
(((pixmap.height() as i32 - scaled_size.height() as i32) / 2) as f32 / scale).round() as i32,
|
|
||||||
loaded_icon.pixmap.as_ref(),
|
|
||||||
&PixmapPaint {
|
|
||||||
opacity: filter.transform.alpha,
|
|
||||||
blend_mode: BlendMode::SourceOver,
|
|
||||||
quality: FilterQuality::Bicubic,
|
|
||||||
},
|
|
||||||
Transform::from_scale(scale, scale).post_rotate_at(
|
|
||||||
(filter.transform.clockwise_quarter_rotations as f32) * 90.0,
|
|
||||||
pixmap.width() as f32 / 2.0,
|
|
||||||
pixmap.height() as f32 / 2.0,
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use log::{error, trace, warn};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub command: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle(config: Arc<Config>, mut events: broadcast::Receiver<KeyEvent>) {
|
|
||||||
while let Ok(event) = events.recv().await {
|
|
||||||
if let KeyEvent::Press = event {
|
|
||||||
let result = Command::new("sh")
|
|
||||||
.args(["-c", &config.command])
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.status();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(status) => {
|
|
||||||
if status.success() {
|
|
||||||
trace!("Command `{}` exited with status code 0", config.command);
|
|
||||||
} else {
|
|
||||||
warn!("Command `{}` exited with status code {}", config.command, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => error!("Command `{}` could not be executed: {}", config.command, error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use deckster_shared::state::KeyStyleByStateMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SwitchConfig {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KeyStyleByStateMap<SwitchState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ButtonConfig {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KeyStyleByStateMap<ButtonState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum SwitchState {
|
|
||||||
Unavailable,
|
|
||||||
On,
|
|
||||||
Off,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum ButtonState {
|
|
||||||
Unavailable,
|
|
||||||
Available,
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerCommand;
|
|
||||||
use deckster_shared::path::KeyPath;
|
|
||||||
|
|
||||||
use crate::model;
|
|
||||||
|
|
||||||
pub mod command;
|
|
||||||
pub mod home_assistant;
|
|
||||||
pub mod playerctl;
|
|
||||||
pub mod timer;
|
|
||||||
|
|
||||||
pub fn start_handlers(
|
|
||||||
keys: impl Iterator<Item = (KeyPath, Arc<model::key_page::Key>)>,
|
|
||||||
events: broadcast::Sender<(KeyPath, KeyEvent)>,
|
|
||||||
commands: flume::Sender<HandlerCommand>,
|
|
||||||
) {
|
|
||||||
for (path, config) in keys {
|
|
||||||
let mut events = events.subscribe();
|
|
||||||
let own_events = broadcast::Sender::new(5);
|
|
||||||
|
|
||||||
if let Some(c) = &config.mode.command {
|
|
||||||
tokio::spawn(command::handle(Arc::clone(c), own_events.subscribe()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &config.mode.playerctl__button {
|
|
||||||
tokio::spawn(playerctl::handle_button(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &config.mode.playerctl__shuffle {
|
|
||||||
tokio::spawn(playerctl::handle_shuffle(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(c) = &config.mode.playerctl__loop {
|
|
||||||
tokio::spawn(playerctl::handle_loop(path.clone(), Arc::clone(c), own_events.subscribe(), commands.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Ok((p, e)) = events.recv().await {
|
|
||||||
#[allow(clippy::collapsible_if)]
|
|
||||||
if p == path {
|
|
||||||
if own_events.send(e).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,283 +0,0 @@
|
||||||
use std::fmt::Debug;
|
|
||||||
use std::hash::Hash;
|
|
||||||
use std::io::{BufRead, BufReader};
|
|
||||||
use std::process::Stdio;
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
use std::thread;
|
|
||||||
use std::thread::sleep;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use log::{error, warn};
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerCommand;
|
|
||||||
use deckster_shared::path::KeyPath;
|
|
||||||
use deckster_shared::state::KeyStyleByStateMap;
|
|
||||||
|
|
||||||
use crate::modes::key::KeyEvent;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ButtonConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KeyStyleByStateMap<ButtonState>,
|
|
||||||
pub command: ButtonCommand,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum ButtonCommand {
|
|
||||||
PlayPause,
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Previous,
|
|
||||||
Next,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum ButtonState {
|
|
||||||
Inactive,
|
|
||||||
Playing,
|
|
||||||
Paused,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ShuffleConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KeyStyleByStateMap<ShuffleState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum ShuffleState {
|
|
||||||
Inactive,
|
|
||||||
Off,
|
|
||||||
On,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct LoopConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KeyStyleByStateMap<LoopState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum LoopState {
|
|
||||||
Inactive,
|
|
||||||
None,
|
|
||||||
Single,
|
|
||||||
All,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PlayerctlStateWatcher<S: Clone + Debug + Send + Sync + 'static> {
|
|
||||||
state: Arc<RwLock<S>>,
|
|
||||||
new_states: broadcast::Sender<S>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S: Clone + Debug + Send + Sync + 'static> PlayerctlStateWatcher<S> {
|
|
||||||
pub fn new(initial_state: S, subcommand: &'static str, parse_state: impl 'static + Fn(&String) -> S + Send) -> Self {
|
|
||||||
let state = Arc::new(RwLock::new(initial_state));
|
|
||||||
let cloned_state = Arc::clone(&state);
|
|
||||||
|
|
||||||
let new_states = broadcast::Sender::new(1);
|
|
||||||
let cloned_new_states = new_states.clone();
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
let players = std::process::Command::new("playerctl")
|
|
||||||
.args(["-s", "-l"])
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if players.stdout.iter().filter(|c| **c == b'\n').count() > 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(Duration::from_secs(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
let command = std::process::Command::new("playerctl")
|
|
||||||
.args(["-s", "-F", subcommand])
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let stdout = BufReader::new(command.stdout.unwrap());
|
|
||||||
|
|
||||||
for line in stdout.lines() {
|
|
||||||
let line = line.unwrap();
|
|
||||||
let new_state = parse_state(&line);
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut g = cloned_state.write().unwrap();
|
|
||||||
*g = new_state.clone();
|
|
||||||
if cloned_new_states.send(new_state).is_err() {
|
|
||||||
warn!("No one is listening to player state changes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
PlayerctlStateWatcher { state, new_states }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subscribe_to_state(&self) -> broadcast::Receiver<S> {
|
|
||||||
let r = self.new_states.subscribe();
|
|
||||||
self.new_states.send(self.state.read().unwrap().clone()).unwrap();
|
|
||||||
r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static STATE_WATCHER_PLAYING: Lazy<PlayerctlStateWatcher<ButtonState>> = Lazy::new(|| {
|
|
||||||
PlayerctlStateWatcher::new(ButtonState::Inactive, "status", |s| match s.as_ref() {
|
|
||||||
"Playing" => ButtonState::Playing,
|
|
||||||
"Paused" => ButtonState::Paused,
|
|
||||||
"" | "Stopped" => ButtonState::Inactive,
|
|
||||||
_ => panic!("Unknown state: {}", s),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
static STATE_WATCHER_SHUFFLE: Lazy<PlayerctlStateWatcher<ShuffleState>> = Lazy::new(|| {
|
|
||||||
PlayerctlStateWatcher::new(ShuffleState::Inactive, "shuffle", |s| match s.as_ref() {
|
|
||||||
"Off" => ShuffleState::Off,
|
|
||||||
"On" => ShuffleState::On,
|
|
||||||
"" => ShuffleState::Inactive,
|
|
||||||
_ => panic!("Unknown state: {}", s),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
static STATE_WATCHER_LOOP: Lazy<PlayerctlStateWatcher<LoopState>> = Lazy::new(|| {
|
|
||||||
PlayerctlStateWatcher::new(LoopState::Inactive, "loop", |s| match s.as_ref() {
|
|
||||||
"Track" => LoopState::Single,
|
|
||||||
"Playlist" => LoopState::All,
|
|
||||||
"None" => LoopState::None,
|
|
||||||
"" => LoopState::Inactive,
|
|
||||||
_ => panic!("Unknown state: {}", s),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
pub async fn handle_button(path: KeyPath, config: Arc<ButtonConfig>, mut events: broadcast::Receiver<KeyEvent>, commands: flume::Sender<HandlerCommand>) {
|
|
||||||
let mut is_active = false;
|
|
||||||
let mut state = STATE_WATCHER_PLAYING.subscribe_to_state();
|
|
||||||
|
|
||||||
let command = match config.command {
|
|
||||||
ButtonCommand::PlayPause => "play-pause",
|
|
||||||
ButtonCommand::Play => "play",
|
|
||||||
ButtonCommand::Pause => "pause",
|
|
||||||
ButtonCommand::Previous => "previous",
|
|
||||||
ButtonCommand::Next => "next",
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
result = state.recv() => {
|
|
||||||
match result {
|
|
||||||
Err(RecvError::Closed) => { result.unwrap(); },
|
|
||||||
Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ },
|
|
||||||
Ok(state) => {
|
|
||||||
is_active = state != ButtonState::Inactive;
|
|
||||||
|
|
||||||
commands.send(HandlerCommand::SetKeyStyle {
|
|
||||||
path: path.clone(),
|
|
||||||
value: config.style.get(&state).cloned()
|
|
||||||
}).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(event) = events.recv() => {
|
|
||||||
if event == KeyEvent::Press && is_active {
|
|
||||||
let status = std::process::Command::new("playerctl").arg(command).status().unwrap();
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
error!("`playerctl {}` failed with exit code {}", command, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_shuffle(path: KeyPath, config: Arc<ShuffleConfig>, mut events: broadcast::Receiver<KeyEvent>, commands: flume::Sender<HandlerCommand>) {
|
|
||||||
let mut state = STATE_WATCHER_SHUFFLE.subscribe_to_state();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
result = state.recv() => {
|
|
||||||
match result {
|
|
||||||
Err(RecvError::Closed) => { result.unwrap(); },
|
|
||||||
Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ },
|
|
||||||
Ok(state) => {
|
|
||||||
commands.send(HandlerCommand::SetKeyStyle {
|
|
||||||
path: path.clone(),
|
|
||||||
value: config.style.get(&state).cloned()
|
|
||||||
}).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(event) = events.recv() => {
|
|
||||||
if event == KeyEvent::Press {
|
|
||||||
let current = *STATE_WATCHER_SHUFFLE.state.read().unwrap();
|
|
||||||
let new = match current {
|
|
||||||
ShuffleState::Inactive | ShuffleState::Off => "On",
|
|
||||||
ShuffleState::On => "Off",
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = std::process::Command::new("playerctl").args(["shuffle", new]).status().unwrap();
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
error!("`playerctl shuffle {}` failed with exit code {}", new, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_loop(path: KeyPath, config: Arc<LoopConfig>, mut events: broadcast::Receiver<KeyEvent>, commands: flume::Sender<HandlerCommand>) {
|
|
||||||
let mut state = STATE_WATCHER_LOOP.subscribe_to_state();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
result = state.recv() => {
|
|
||||||
match result {
|
|
||||||
Err(RecvError::Closed) => { result.unwrap(); },
|
|
||||||
Err(RecvError::Lagged(_)) => { /* mission failed, we'll get 'em next time */ },
|
|
||||||
Ok(state) => {
|
|
||||||
commands.send(HandlerCommand::SetKeyStyle {
|
|
||||||
path: path.clone(),
|
|
||||||
value: config.style.get(&state).cloned()
|
|
||||||
}).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(event) = events.recv() => {
|
|
||||||
if event == KeyEvent::Press {
|
|
||||||
let current = *STATE_WATCHER_LOOP.state.read().unwrap();
|
|
||||||
let new = match current {
|
|
||||||
LoopState::Inactive | LoopState::None => "Playlist",
|
|
||||||
LoopState::All => "Track",
|
|
||||||
LoopState::Single => "None",
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = std::process::Command::new("playerctl").args(["loop", new]).status().unwrap();
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
error!("`playerctl loop {}` failed with exit code {}", new, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub durations: Vec<DurationWrapper>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub vibrate_when_finished: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub needy: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct DurationWrapper(#[serde(with = "humantime_serde")] pub Duration);
|
|
|
@ -1,274 +0,0 @@
|
||||||
use std::borrow::ToOwned;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use log::warn;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parse_display::Display;
|
|
||||||
use regex::Regex;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerCommand;
|
|
||||||
use deckster_shared::handler_communication::{KnobEvent, RotationDirection};
|
|
||||||
use deckster_shared::path::KnobPath;
|
|
||||||
use deckster_shared::state::KnobStyleByStateMap;
|
|
||||||
use pa_volume_interface::{PaEntityKind, PaEntityMetadata, PaEntityState, PaVolumeInterface};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub target: Target,
|
|
||||||
pub delta: Option<f32>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub disable_press_to_mute: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub disable_press_to_unmute: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub muted_turn_action: MutedTurnAction,
|
|
||||||
#[serde(default)]
|
|
||||||
pub style: KnobStyleByStateMap<State>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Deserialize, Display)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
#[display(style = "kebab-case")]
|
|
||||||
pub enum TargetKind {
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
Application,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Target {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
kind: TargetKind,
|
|
||||||
predicates: Vec<TargetPredicate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct TargetPredicate {
|
|
||||||
property: TargetPredicateProperty,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pattern: TargetPredicatePattern,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum TargetPredicatePattern {
|
|
||||||
Static {
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
Regex {
|
|
||||||
#[serde(with = "serde_regex")]
|
|
||||||
regex: Regex,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Deserialize, Display)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
#[display(style = "kebab-case")]
|
|
||||||
pub enum TargetPredicateProperty {
|
|
||||||
Description,
|
|
||||||
InternalName,
|
|
||||||
ApplicationName,
|
|
||||||
BinaryName,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Eq, PartialEq, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum MutedTurnAction {
|
|
||||||
#[default]
|
|
||||||
Ignore,
|
|
||||||
Normal,
|
|
||||||
UnmuteAtZero,
|
|
||||||
Unmute,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Eq, PartialEq, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum Direction {
|
|
||||||
#[default]
|
|
||||||
Output,
|
|
||||||
Input,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Deserialize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum State {
|
|
||||||
Inactive,
|
|
||||||
Active,
|
|
||||||
Muted,
|
|
||||||
}
|
|
||||||
|
|
||||||
static PA_VOLUME_INTERFACE: Lazy<PaVolumeInterface> = Lazy::new(|| PaVolumeInterface::spawn_thread("deckster".to_owned()));
|
|
||||||
|
|
||||||
fn get_volume_from_cv(channel_volumes: &[f32]) -> f32 {
|
|
||||||
*channel_volumes.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_matches(target: &Target, state: &PaEntityState) -> bool {
|
|
||||||
if !match target.kind {
|
|
||||||
TargetKind::Input => state.kind() == PaEntityKind::Source,
|
|
||||||
TargetKind::Output => state.kind() == PaEntityKind::Sink,
|
|
||||||
TargetKind::Application => state.kind() == PaEntityKind::SinkInput,
|
|
||||||
} {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static EMPTY_STRING: String = String::new();
|
|
||||||
|
|
||||||
return target.predicates.iter().all(|p| {
|
|
||||||
let v = match (&p.property, state.metadata()) {
|
|
||||||
(TargetPredicateProperty::InternalName, PaEntityMetadata::Sink { name, .. }) => Some(name),
|
|
||||||
(TargetPredicateProperty::InternalName, PaEntityMetadata::Source { name, .. }) => Some(name),
|
|
||||||
(TargetPredicateProperty::InternalName, PaEntityMetadata::SinkInput { .. }) => None,
|
|
||||||
(TargetPredicateProperty::Description, PaEntityMetadata::Sink { description, .. }) => Some(description),
|
|
||||||
(TargetPredicateProperty::Description, PaEntityMetadata::Source { description, .. }) => Some(description),
|
|
||||||
(TargetPredicateProperty::Description, PaEntityMetadata::SinkInput { description, .. }) => Some(description),
|
|
||||||
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Sink { .. }) => None,
|
|
||||||
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Source { .. }) => None,
|
|
||||||
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::SinkInput { application_name, .. }) => {
|
|
||||||
Some(application_name.as_ref().unwrap_or(&EMPTY_STRING))
|
|
||||||
}
|
|
||||||
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Sink { .. }) => None,
|
|
||||||
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Source { .. }) => None,
|
|
||||||
(TargetPredicateProperty::BinaryName, PaEntityMetadata::SinkInput { binary_name, .. }) => Some(binary_name.as_ref().unwrap_or(&EMPTY_STRING)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(v) = v {
|
|
||||||
match &p.pattern {
|
|
||||||
TargetPredicatePattern::Static { value } => value == v,
|
|
||||||
TargetPredicatePattern::Regex { regex } => regex.is_match(v),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("Property \"{}\" is not available for targets of type \"{}\"", &p.property, &target.kind);
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle(path: KnobPath, config: Arc<Config>, mut events: broadcast::Receiver<(KnobPath, KnobEvent)>, commands: flume::Sender<HandlerCommand>) {
|
|
||||||
let mut entity_state: Option<Arc<PaEntityState>> = None;
|
|
||||||
|
|
||||||
let pa_volume_interface = &PA_VOLUME_INTERFACE;
|
|
||||||
let (initial_state, mut volume_states) = pa_volume_interface.subscribe_to_state();
|
|
||||||
|
|
||||||
let update_knob_value = {
|
|
||||||
let commands = commands.clone();
|
|
||||||
let config = Arc::clone(&config);
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
move |entity_state: &Option<Arc<PaEntityState>>| {
|
|
||||||
commands
|
|
||||||
.send(HandlerCommand::SetKnobValue {
|
|
||||||
path: path.clone(),
|
|
||||||
value: entity_state.as_ref().map(|s| {
|
|
||||||
if s.is_muted() && config.muted_turn_action == MutedTurnAction::UnmuteAtZero {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
get_volume_from_cv(&s.channel_volumes())
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let update_knob_style = {
|
|
||||||
let commands = commands.clone();
|
|
||||||
let config = Arc::clone(&config);
|
|
||||||
let path = path.clone();
|
|
||||||
|
|
||||||
move |entity_state: &Option<Arc<PaEntityState>>| {
|
|
||||||
let state = match entity_state {
|
|
||||||
None => State::Inactive,
|
|
||||||
Some(s) if s.is_muted() => State::Muted,
|
|
||||||
Some(_) => State::Active,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut style = config.style.get(&state).cloned();
|
|
||||||
|
|
||||||
if let Some(ref mut s) = &mut style {
|
|
||||||
let v = entity_state.as_ref().map(|s| get_volume_from_cv(&s.channel_volumes()));
|
|
||||||
|
|
||||||
if let Some(ref mut label) = &mut s.label {
|
|
||||||
if let Some(v) = v {
|
|
||||||
// v is only None when state is State::Inactive
|
|
||||||
*label = label.replace("{percentage}", &((v * 100.0).round() as u32).to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commands
|
|
||||||
.send(HandlerCommand::SetKnobStyle {
|
|
||||||
path: path.clone(),
|
|
||||||
value: style,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(state) = initial_state {
|
|
||||||
entity_state = state
|
|
||||||
.entities_by_id()
|
|
||||||
.values()
|
|
||||||
.find(|entity| state_matches(&config.target, &entity))
|
|
||||||
.map(Arc::clone);
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
Ok(volume_state) = volume_states.recv() => {
|
|
||||||
entity_state = volume_state.entities_by_id().values().find(|entity| state_matches(&config.target, &entity)).map(Arc::clone);
|
|
||||||
update_knob_style(&entity_state);
|
|
||||||
update_knob_value(&entity_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((event_path, event)) = events.recv() => {
|
|
||||||
if event_path != path {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(entity_state) = &entity_state {
|
|
||||||
match event {
|
|
||||||
KnobEvent::Rotate { direction } => {
|
|
||||||
let factor: f32 = match direction {
|
|
||||||
RotationDirection::Clockwise => 1.0,
|
|
||||||
RotationDirection::Counterclockwise => -1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut current_v = get_volume_from_cv(&entity_state.channel_volumes());
|
|
||||||
|
|
||||||
if entity_state.is_muted() {
|
|
||||||
match config.muted_turn_action {
|
|
||||||
MutedTurnAction::Ignore => continue,
|
|
||||||
MutedTurnAction::UnmuteAtZero => {
|
|
||||||
current_v = 0.0
|
|
||||||
}
|
|
||||||
MutedTurnAction::Unmute => {},
|
|
||||||
MutedTurnAction::Normal => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_v = (current_v + (factor * config.delta.unwrap_or(0.01))).clamp(0.0, 1.0);
|
|
||||||
if new_v > 0.0 && matches!(config.muted_turn_action, MutedTurnAction::Unmute | MutedTurnAction::UnmuteAtZero) {
|
|
||||||
pa_volume_interface.set_is_muted(*entity_state.id(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
pa_volume_interface.set_channel_volumes(*entity_state.id(), vec![new_v; entity_state.channel_volumes().len()]);
|
|
||||||
}
|
|
||||||
KnobEvent::Press => {
|
|
||||||
let is_muted = entity_state.is_muted();
|
|
||||||
|
|
||||||
if (is_muted && !config.disable_press_to_unmute) || (!is_muted && !config.disable_press_to_mute) {
|
|
||||||
pa_volume_interface.set_is_muted(*entity_state.id(), !is_muted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerCommand;
|
|
||||||
use deckster_shared::handler_communication::KnobEvent;
|
|
||||||
use deckster_shared::path::KnobPath;
|
|
||||||
|
|
||||||
use crate::model;
|
|
||||||
|
|
||||||
pub mod audio_volume;
|
|
||||||
|
|
||||||
pub fn start_handlers(
|
|
||||||
knobs: impl Iterator<Item = (KnobPath, Arc<model::knob_page::Knob>)>,
|
|
||||||
events: broadcast::Sender<(KnobPath, KnobEvent)>,
|
|
||||||
commands: flume::Sender<HandlerCommand>,
|
|
||||||
) {
|
|
||||||
for (path, config) in knobs {
|
|
||||||
if let Some(c) = &config.mode.audio_volume {
|
|
||||||
tokio::spawn(audio_volume::handle(path.clone(), Arc::clone(c), events.subscribe(), commands.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod key;
|
|
||||||
pub mod knob;
|
|
|
@ -1,44 +0,0 @@
|
||||||
use std::io;
|
|
||||||
|
|
||||||
use either::Either;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerEvent;
|
|
||||||
use deckster_shared::path::{KeyPath, KnobPath};
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum RunError {
|
|
||||||
#[error("A stdin line could not be read.")]
|
|
||||||
LineIo(#[from] io::Error),
|
|
||||||
|
|
||||||
#[error("A stdin line could not be deserialized.")]
|
|
||||||
LineDeserialization { line: String, source: toml::de::Error },
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait DecksterHandler {
|
|
||||||
fn handle(&mut self, event: HandlerEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run<H: DecksterHandler, I: FnOnce(im::HashMap<KeyPath, (String, toml::Value)>, im::HashMap<KnobPath, (String, toml::Value)>) -> H>(
|
|
||||||
init_handler: I,
|
|
||||||
) -> Result<(), RunError> {
|
|
||||||
let mut handler: Either<H, I> = Either::Right(init_handler);
|
|
||||||
|
|
||||||
for line in io::stdin().lines() {
|
|
||||||
let line = line?;
|
|
||||||
|
|
||||||
match handler {
|
|
||||||
Either::Left(mut h) => {
|
|
||||||
let event: HandlerEvent = toml::from_str(&line).map_err(|e| RunError::LineDeserialization { line, source: e.clone() })?;
|
|
||||||
|
|
||||||
h.handle(event);
|
|
||||||
handler = Either::Left(h);
|
|
||||||
}
|
|
||||||
Either::Right(init_handler) => {
|
|
||||||
handler = Either::Left(init_handler(im::HashMap::new(), im::HashMap::new()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
use std::fmt::{Display, Formatter};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use enum_map::Enum;
|
|
||||||
use enum_ordinalize::Ordinalize;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, SerializeDisplay, DeserializeFromStr)]
|
|
||||||
/// One-based coordinates of a specific virtual (not physical) key.
|
|
||||||
pub struct KeyPosition {
|
|
||||||
pub x: u16,
|
|
||||||
pub y: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
#[error("The input value does not match the required format of <x> and <y> separated by an 'x'")]
|
|
||||||
pub struct KeyPositionFromStrError {}
|
|
||||||
|
|
||||||
impl FromStr for KeyPosition {
|
|
||||||
type Err = KeyPositionFromStrError;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
let values = s.split_once('x');
|
|
||||||
|
|
||||||
if let Some((x, y)) = values {
|
|
||||||
if let Ok(x) = u16::from_str(x) {
|
|
||||||
if let Ok(y) = u16::from_str(y) {
|
|
||||||
return Ok(KeyPosition { x, y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(KeyPositionFromStrError {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for KeyPosition {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_fmt(format_args!("{}x{}", self.x, self.y))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct KeyPath {
|
|
||||||
pub page_id: String,
|
|
||||||
pub position: KeyPosition,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for KeyPath {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_fmt(format_args!("{}/{}", &self.page_id, &self.position))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize, Enum, Ordinalize)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum KnobPosition {
|
|
||||||
LeftTop,
|
|
||||||
LeftMiddle,
|
|
||||||
LeftBottom,
|
|
||||||
RightTop,
|
|
||||||
RightMiddle,
|
|
||||||
RightBottom,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KnobPosition {
|
|
||||||
pub fn is_left(&self) -> bool {
|
|
||||||
matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct KnobPath {
|
|
||||||
pub page_id: String,
|
|
||||||
pub position: KnobPosition,
|
|
||||||
}
|
|
|
@ -1,22 +1,7 @@
|
||||||
inactive_button_color = "#000060"
|
inactive_button_color = "#000060"
|
||||||
active_button_color = "#eeffff"
|
active_button_color = "#eeffff"
|
||||||
label_font_family = "Inter"
|
label_font_family = "Inter"
|
||||||
|
buttons = { }
|
||||||
[buttons.0]
|
|
||||||
key_page = "default"
|
|
||||||
knob_page = "default"
|
|
||||||
|
|
||||||
[buttons.1]
|
|
||||||
key_page = "numpad"
|
|
||||||
knob_page = "default"
|
|
||||||
|
|
||||||
[buttons.2]
|
|
||||||
key_page = "emojis"
|
|
||||||
knob_page = "default"
|
|
||||||
|
|
||||||
[buttons.3]
|
|
||||||
key_page = "special_chars"
|
|
||||||
knob_page = "default"
|
|
||||||
|
|
||||||
[initial]
|
[initial]
|
||||||
key_page = "default"
|
key_page = "default"
|
BIN
examples/full/handlers/pa_volume
Executable file
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 764 B |
Before Width: | Height: | Size: 679 B After Width: | Height: | Size: 679 B |
Before Width: | Height: | Size: 441 B After Width: | Height: | Size: 441 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 532 B After Width: | Height: | Size: 532 B |
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 378 B |
Before Width: | Height: | Size: 384 B After Width: | Height: | Size: 384 B |
Before Width: | Height: | Size: 649 B After Width: | Height: | Size: 649 B |
Before Width: | Height: | Size: 342 B After Width: | Height: | Size: 342 B |
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 402 B |
Before Width: | Height: | Size: 316 B After Width: | Height: | Size: 316 B |
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 314 B |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
Before Width: | Height: | Size: 364 B After Width: | Height: | Size: 364 B |
|
@ -2,7 +2,7 @@
|
||||||
icon = "@ph/microphone-light[scale=0.9]"
|
icon = "@ph/microphone-light[scale=0.9]"
|
||||||
indicators.bar.color = "#ffffff50"
|
indicators.bar.color = "#ffffff50"
|
||||||
|
|
||||||
handler = "audio_volume"
|
handler = "pa_volume"
|
||||||
config.delta = 0.05
|
config.delta = 0.05
|
||||||
config.target.type = "input"
|
config.target.type = "input"
|
||||||
config.target.predicates = [{ property = "description", value = "SC425 USB Microphone Analog Stereo" }]
|
config.target.predicates = [{ property = "description", value = "SC425 USB Microphone Analog Stereo" }]
|
||||||
|
@ -19,7 +19,7 @@ config.style.inactive.icon = "@ph/microphone-slash-light[scale=0.9|alpha=0.8|col
|
||||||
icon = "@apps/discord[scale=0.25]"
|
icon = "@apps/discord[scale=0.25]"
|
||||||
indicators.bar.color = "#ffffff50"
|
indicators.bar.color = "#ffffff50"
|
||||||
|
|
||||||
handler = "audio_volume"
|
handler = "pa_volume"
|
||||||
config.delta = 0.05
|
config.delta = 0.05
|
||||||
config.target.type = "application"
|
config.target.type = "application"
|
||||||
config.target.predicates = [{ property = "binary-name", value = "Discord" }, { property = "description", value = "playStream" }]
|
config.target.predicates = [{ property = "binary-name", value = "Discord" }, { property = "description", value = "playStream" }]
|
||||||
|
@ -31,7 +31,7 @@ config.style.inactive.icon = "@apps/discord[scale=0.25|grayscale|alpha=0.8]"
|
||||||
icon = "@apps/youtube[scale=1.3]"
|
icon = "@apps/youtube[scale=1.3]"
|
||||||
indicators.bar.color = "#ffffff50"
|
indicators.bar.color = "#ffffff50"
|
||||||
|
|
||||||
handler = "audio_volume"
|
handler = "pa_volume"
|
||||||
config.delta = 0.05
|
config.delta = 0.05
|
||||||
config.muted_turn_action = "unmute"
|
config.muted_turn_action = "unmute"
|
||||||
config.target.type = "application"
|
config.target.type = "application"
|
||||||
|
@ -44,7 +44,7 @@ config.style.inactive.icon = "@apps/youtube[scale=1.3|grayscale]"
|
||||||
icon = "@apps/spotify[scale=1.2]"
|
icon = "@apps/spotify[scale=1.2]"
|
||||||
indicators.bar.color = "#ffffff50"
|
indicators.bar.color = "#ffffff50"
|
||||||
|
|
||||||
handler = "audio_volume"
|
handler = "pa_volume"
|
||||||
config.delta = 0.05
|
config.delta = 0.05
|
||||||
config.muted_turn_action = "unmute-at-zero"
|
config.muted_turn_action = "unmute-at-zero"
|
||||||
config.target.type = "application"
|
config.target.type = "application"
|
17
handlers/pa_volume/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "pa_volume"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
deckster_mode = { path = "../../crates/deckster_mode" }
|
||||||
|
pa_volume_interface = { path = "../../crates/pa_volume_interface" }
|
||||||
|
clap = { version = "4.4.18", features = ["derive"] }
|
||||||
|
color-eyre = "0.6.2"
|
||||||
|
serde = { version = "1.0.196", features = ["derive"] }
|
||||||
|
serde_regex = "1.1.0"
|
||||||
|
parse-display = "0.8.2"
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
env_logger = "0.11.1"
|
||||||
|
log = "0.4.20"
|
||||||
|
im = "15.1.0"
|
159
handlers/pa_volume/src/handler.rs
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
use log::warn;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parse_display::helpers::regex::Regex;
|
||||||
|
use parse_display::Display;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use deckster_mode::{DecksterHandler, HandlerEvent, HandlerInitializationError, InitialHandlerMessage, KnobPath, KnobStyleByStateMap};
|
||||||
|
use pa_volume_interface::{PaEntityKind, PaEntityMetadata, PaEntityState, PaVolumeInterface};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct KnobConfig {
|
||||||
|
pub target: Target,
|
||||||
|
pub delta: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_press_to_mute: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_press_to_unmute: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub muted_turn_action: MutedTurnAction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub style: KnobStyleByStateMap<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Display)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
#[display(style = "kebab-case")]
|
||||||
|
pub enum TargetKind {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
Application,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Target {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: TargetKind,
|
||||||
|
predicates: Vec<TargetPredicate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct TargetPredicate {
|
||||||
|
property: TargetPredicateProperty,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pattern: TargetPredicatePattern,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum TargetPredicatePattern {
|
||||||
|
Static {
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
Regex {
|
||||||
|
#[serde(with = "serde_regex")]
|
||||||
|
regex: Regex,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Display)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
#[display(style = "kebab-case")]
|
||||||
|
pub enum TargetPredicateProperty {
|
||||||
|
Description,
|
||||||
|
InternalName,
|
||||||
|
ApplicationName,
|
||||||
|
BinaryName,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum MutedTurnAction {
|
||||||
|
#[default]
|
||||||
|
Ignore,
|
||||||
|
Normal,
|
||||||
|
UnmuteAtZero,
|
||||||
|
Unmute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Direction {
|
||||||
|
#[default]
|
||||||
|
Output,
|
||||||
|
Input,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum State {
|
||||||
|
Inactive,
|
||||||
|
Active,
|
||||||
|
Muted,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PA_VOLUME_INTERFACE: Lazy<PaVolumeInterface> = Lazy::new(|| PaVolumeInterface::spawn_thread("deckster".to_owned()));
|
||||||
|
|
||||||
|
fn get_volume_from_cv(channel_volumes: &[f32]) -> f32 {
|
||||||
|
*channel_volumes.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_matches(target: &Target, state: &PaEntityState) -> bool {
|
||||||
|
if !match target.kind {
|
||||||
|
TargetKind::Input => state.kind() == PaEntityKind::Source,
|
||||||
|
TargetKind::Output => state.kind() == PaEntityKind::Sink,
|
||||||
|
TargetKind::Application => state.kind() == PaEntityKind::SinkInput,
|
||||||
|
} {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static EMPTY_STRING: String = String::new();
|
||||||
|
|
||||||
|
return target.predicates.iter().all(|p| {
|
||||||
|
let v = match (&p.property, state.metadata()) {
|
||||||
|
(TargetPredicateProperty::InternalName, PaEntityMetadata::Sink { name, .. }) => Some(name),
|
||||||
|
(TargetPredicateProperty::InternalName, PaEntityMetadata::Source { name, .. }) => Some(name),
|
||||||
|
(TargetPredicateProperty::InternalName, PaEntityMetadata::SinkInput { .. }) => None,
|
||||||
|
(TargetPredicateProperty::Description, PaEntityMetadata::Sink { description, .. }) => Some(description),
|
||||||
|
(TargetPredicateProperty::Description, PaEntityMetadata::Source { description, .. }) => Some(description),
|
||||||
|
(TargetPredicateProperty::Description, PaEntityMetadata::SinkInput { description, .. }) => Some(description),
|
||||||
|
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Sink { .. }) => None,
|
||||||
|
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Source { .. }) => None,
|
||||||
|
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::SinkInput { application_name, .. }) => {
|
||||||
|
Some(application_name.as_ref().unwrap_or(&EMPTY_STRING))
|
||||||
|
}
|
||||||
|
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Sink { .. }) => None,
|
||||||
|
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Source { .. }) => None,
|
||||||
|
(TargetPredicateProperty::BinaryName, PaEntityMetadata::SinkInput { binary_name, .. }) => Some(binary_name.as_ref().unwrap_or(&EMPTY_STRING)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(v) = v {
|
||||||
|
match &p.pattern {
|
||||||
|
TargetPredicatePattern::Static { value } => value == v,
|
||||||
|
TargetPredicatePattern::Regex { regex } => regex.is_match(v),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Property \"{}\" is not available for targets of type \"{}\"", &p.property, &target.kind);
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Handler {
|
||||||
|
knobs: im::HashMap<KnobPath, KnobConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler {
|
||||||
|
pub fn new(data: InitialHandlerMessage<(), KnobConfig>) -> Result<Self, HandlerInitializationError> {
|
||||||
|
Ok(Handler {
|
||||||
|
knobs: data.knob_configs.into_iter().map(|(p, (_, c))| (p, c)).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecksterHandler for Handler {
|
||||||
|
fn handle(&mut self, event: HandlerEvent) {
|
||||||
|
dbg!(&self.knobs, event);
|
||||||
|
}
|
||||||
|
}
|
28
handlers/pa_volume/src/main.rs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
use clap::Parser;
|
||||||
|
use color_eyre::Result;
|
||||||
|
|
||||||
|
use crate::handler::Handler;
|
||||||
|
|
||||||
|
mod handler;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "pa_volume")]
|
||||||
|
enum CliCommand {
|
||||||
|
#[command(about = "Print all currently available entities")]
|
||||||
|
Entities,
|
||||||
|
#[command(name = "deckster-run", hide = true)]
|
||||||
|
Run,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let command = CliCommand::parse();
|
||||||
|
|
||||||
|
match command {
|
||||||
|
CliCommand::Entities => todo!(),
|
||||||
|
CliCommand::Run => {
|
||||||
|
deckster_mode::run(Handler::new)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
157
src/handler_host/mod.rs
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use color_eyre::eyre::{eyre, WrapErr};
|
||||||
|
use color_eyre::Result;
|
||||||
|
use is_executable::IsExecutable;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::{ChildStdin, Command};
|
||||||
|
|
||||||
|
use deckster_shared::handler_communication::{
|
||||||
|
HandlerCommand, HandlerEvent, HandlerInitializationError, HandlerInitializationResultMessage, InitialHandlerMessage,
|
||||||
|
};
|
||||||
|
use deckster_shared::path::{KeyPath, KnobPath};
|
||||||
|
|
||||||
|
pub struct KeyOrKnobConfig {
|
||||||
|
pub handler_name: Box<str>,
|
||||||
|
pub mode_string: Box<str>,
|
||||||
|
pub handler_config: Arc<toml::Table>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
handlers_directory: &Path,
|
||||||
|
key_configs: HashMap<KeyPath, KeyOrKnobConfig>,
|
||||||
|
knob_configs: HashMap<KnobPath, KeyOrKnobConfig>,
|
||||||
|
commands_sender: flume::Sender<HandlerCommand>,
|
||||||
|
events_receiver: flume::Receiver<HandlerEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let handler_names: Vec<OsString> = std::fs::read_dir(handlers_directory)
|
||||||
|
.wrap_err_with(|| format!("while reading the handlers directory: {}", handlers_directory.to_string_lossy()))?
|
||||||
|
.filter_map(|entry| {
|
||||||
|
if let Ok(entry) = entry {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_executable() {
|
||||||
|
return Some(path.file_name().unwrap().to_os_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut handler_stdin_by_name: HashMap<Box<str>, ChildStdin> = HashMap::with_capacity(handler_names.len());
|
||||||
|
|
||||||
|
for handler_name in handler_names {
|
||||||
|
let handler_name = handler_name.into_string().map_err(|_| eyre!("Command names must be valid Unicode."))?;
|
||||||
|
|
||||||
|
let mut command = Command::new(handlers_directory.join(&handler_name))
|
||||||
|
.arg("deckster-run")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.wrap_err_with(|| format!("while spawning handler: {}", handler_name))?;
|
||||||
|
|
||||||
|
let mut stdout_lines = BufReader::new(command.stdout.take().unwrap()).lines();
|
||||||
|
let mut stdin = command.stdin.unwrap();
|
||||||
|
|
||||||
|
let initial_handler_message = InitialHandlerMessage {
|
||||||
|
key_configs: key_configs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(path, c)| {
|
||||||
|
if *c.handler_name == handler_name {
|
||||||
|
Some((path.clone(), (c.mode_string.clone(), Arc::clone(&c.handler_config))))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
knob_configs: knob_configs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(path, c)| {
|
||||||
|
if *c.handler_name == handler_name {
|
||||||
|
Some((path.clone(), (c.mode_string.clone(), Arc::clone(&c.handler_config))))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let serialized_message = serde_json::to_string(&initial_handler_message).unwrap().into_boxed_str().into_boxed_bytes();
|
||||||
|
|
||||||
|
stdin.write_all(&serialized_message).await.unwrap();
|
||||||
|
stdin.write_u8(b'\n').await.unwrap();
|
||||||
|
stdin.flush().await.unwrap();
|
||||||
|
|
||||||
|
let result_line = stdout_lines.next_line().await?.unwrap();
|
||||||
|
let result: HandlerInitializationResultMessage = serde_json::from_str(&result_line)?;
|
||||||
|
|
||||||
|
if let HandlerInitializationResultMessage::Error { error } = result {
|
||||||
|
#[rustfmt::skip]
|
||||||
|
if let HandlerInitializationError::InvalidConfig { supports_keys, supports_knobs, .. } = error {
|
||||||
|
if !supports_keys && !initial_handler_message.key_configs.is_empty() {
|
||||||
|
return Err(eyre!(
|
||||||
|
"The '{handler_name}' handler does not support keys, but these keys tried to use it: {:?}",
|
||||||
|
initial_handler_message.key_configs.keys().map(|k| k.to_string()).collect::<String>()
|
||||||
|
));
|
||||||
|
} else if !supports_knobs && !initial_handler_message.knob_configs.is_empty() {
|
||||||
|
return Err(eyre!(
|
||||||
|
"The '{handler_name}' handler does not support knobs, but these knobs tried to use it: {:?}",
|
||||||
|
initial_handler_message.knob_configs.keys().map(|k| k.to_string()).collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Err(eyre!("Starting the '{handler_name}' handler failed: {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands_sender = commands_sender.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(Some(line)) = stdout_lines.next_line().await {
|
||||||
|
if line.starts_with('{') {
|
||||||
|
let command = serde_json::from_str::<HandlerCommand>(&line).unwrap();
|
||||||
|
|
||||||
|
commands_sender.send_async(command).await.unwrap();
|
||||||
|
} else {
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
handler_stdin_by_name.insert(handler_name.into_boxed_str(), stdin);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(event) = events_receiver.recv_async().await {
|
||||||
|
let config = match &event {
|
||||||
|
HandlerEvent::Key { path, .. } => {
|
||||||
|
if let Some(config) = key_configs.get(path) {
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HandlerEvent::Knob { path, .. } => {
|
||||||
|
if let Some(config) = knob_configs.get(path) {
|
||||||
|
config
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let handler_stdin = handler_stdin_by_name.get_mut(&config.handler_name).expect("was already checked above");
|
||||||
|
let serialized_event = serde_json::to_string(&event).unwrap().into_boxed_str().into_boxed_bytes();
|
||||||
|
|
||||||
|
handler_stdin.write_all(&serialized_event).await.unwrap();
|
||||||
|
handler_stdin.write_u8(b'\n').await.unwrap();
|
||||||
|
handler_stdin.flush().await.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
171
src/icons/mod.rs
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use color_eyre::eyre::{eyre, ContextCompat, WrapErr};
|
||||||
|
use color_eyre::Result;
|
||||||
|
use resvg::usvg::fontdb;
|
||||||
|
use resvg::usvg::tiny_skia_path::IntSize;
|
||||||
|
use resvg::usvg::{TextRendering, TreeParsing, TreeTextToPath};
|
||||||
|
use tiny_skia::{BlendMode, FilterQuality, Pixmap, PixmapPaint, Transform};
|
||||||
|
|
||||||
|
use deckster_shared::icon_descriptor::{IconDescriptor, IconDescriptorSource};
|
||||||
|
use deckster_shared::image_filter::ImageFilter;
|
||||||
|
|
||||||
|
use crate::model::config::{IconFormat, IconPack};
|
||||||
|
|
||||||
|
mod destructive_filter;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct IconManager {
|
||||||
|
config_directory: PathBuf,
|
||||||
|
icon_packs_by_id: Arc<HashMap<String, IconPack>>,
|
||||||
|
dpi: f32,
|
||||||
|
fonts_db: fontdb::Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LoadedIcon {
|
||||||
|
pixmap: Pixmap,
|
||||||
|
effective_filter: ImageFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconManager {
|
||||||
|
pub fn new(config_directory: PathBuf, icon_packs_by_id: Arc<HashMap<String, IconPack>>, dpi: f32) -> IconManager {
|
||||||
|
let mut fonts_db = fontdb::Database::new();
|
||||||
|
fonts_db.load_system_fonts();
|
||||||
|
|
||||||
|
IconManager {
|
||||||
|
config_directory,
|
||||||
|
icon_packs_by_id,
|
||||||
|
dpi,
|
||||||
|
fonts_db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add caching
|
||||||
|
fn load_icon(&self, descriptor: &IconDescriptor) -> Result<LoadedIcon> {
|
||||||
|
let icon_pack = if let IconDescriptorSource::IconPack { pack_id, .. } = &descriptor.source {
|
||||||
|
Some(
|
||||||
|
self.icon_packs_by_id
|
||||||
|
.get(pack_id)
|
||||||
|
.wrap_err_with(|| format!("Unknown icon pack: @{}", pack_id))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon_pack_filter = icon_pack.and_then(|p| p.global_filter.as_ref());
|
||||||
|
|
||||||
|
let mut effective_filter = icon_pack_filter
|
||||||
|
.map(|f| descriptor.filter.merge_over(f))
|
||||||
|
.unwrap_or_else(|| descriptor.filter.clone());
|
||||||
|
|
||||||
|
let (original_image, original_image_scale) = read_image_and_get_scale(
|
||||||
|
&self.config_directory,
|
||||||
|
self.dpi,
|
||||||
|
&self.fonts_db,
|
||||||
|
&descriptor.source,
|
||||||
|
icon_pack,
|
||||||
|
effective_filter.transform.scale,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let pixmap = destructive_filter::apply(&original_image, &effective_filter.destructive)?;
|
||||||
|
|
||||||
|
effective_filter.transform.scale /= original_image_scale;
|
||||||
|
|
||||||
|
Ok(LoadedIcon { pixmap, effective_filter })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_icon_in(&self, pixmap: &mut Pixmap, descriptor: &IconDescriptor) -> Result<()> {
|
||||||
|
let icon = self.load_icon(descriptor)?;
|
||||||
|
let scale = icon.effective_filter.transform.scale;
|
||||||
|
|
||||||
|
let scaled_size = IntSize::from_wh(icon.pixmap.width(), icon.pixmap.height()).unwrap().scale_by(scale).unwrap();
|
||||||
|
|
||||||
|
pixmap.draw_pixmap(
|
||||||
|
(((pixmap.width() as i32 - scaled_size.width() as i32) / 2) as f32 / scale).round() as i32,
|
||||||
|
(((pixmap.height() as i32 - scaled_size.height() as i32) / 2) as f32 / scale).round() as i32,
|
||||||
|
icon.pixmap.as_ref(),
|
||||||
|
&PixmapPaint {
|
||||||
|
opacity: icon.effective_filter.transform.alpha,
|
||||||
|
blend_mode: BlendMode::SourceOver,
|
||||||
|
quality: FilterQuality::Bicubic,
|
||||||
|
},
|
||||||
|
Transform::from_scale(scale, scale).post_rotate_at(
|
||||||
|
(icon.effective_filter.transform.clockwise_quarter_rotations as f32) * 90.0,
|
||||||
|
pixmap.width() as f32 / 2.0,
|
||||||
|
pixmap.height() as f32 / 2.0,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_image_and_get_scale(
|
||||||
|
config_directory: &Path,
|
||||||
|
dpi: f32,
|
||||||
|
fonts_db: &resvg::usvg::fontdb::Database,
|
||||||
|
source: &IconDescriptorSource,
|
||||||
|
icon_pack: Option<&IconPack>,
|
||||||
|
preferred_scale: f32,
|
||||||
|
) -> Result<(Pixmap, f32)> {
|
||||||
|
let path = match source {
|
||||||
|
IconDescriptorSource::Path(path) => path.clone(),
|
||||||
|
IconDescriptorSource::IconPack { icon_id, .. } => {
|
||||||
|
let icon_pack = icon_pack.unwrap();
|
||||||
|
let extension = match icon_pack.format {
|
||||||
|
IconFormat::Png => "png",
|
||||||
|
IconFormat::Svg => "svg",
|
||||||
|
};
|
||||||
|
|
||||||
|
config_directory.join(&icon_pack.path).join(icon_id.to_owned() + "." + extension)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(match path.extension() {
|
||||||
|
None => return Err(eyre!("Invalid icon path: {:?}", path)),
|
||||||
|
Some(extension) => match extension.to_string_lossy().as_ref() {
|
||||||
|
"png" => (
|
||||||
|
Pixmap::load_png(&path).wrap_err_with(|| format!("Failed to open or decode the PNG file at {}", path.to_string_lossy()))?,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
"svg" => (
|
||||||
|
read_image_from_svg(&path, dpi, fonts_db, preferred_scale)
|
||||||
|
.wrap_err_with(|| format!("Failed to open or decode the SVG file at {}", path.to_string_lossy()))?,
|
||||||
|
preferred_scale,
|
||||||
|
),
|
||||||
|
extension => return Err(eyre!("Invalid file extension, only *.png and *.svg are allowed: {}", extension)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_image_from_svg(path: &Path, dpi: f32, font_db: &resvg::usvg::fontdb::Database, scale: f32) -> Result<Pixmap> {
|
||||||
|
let raw_data = std::fs::read(path)?;
|
||||||
|
|
||||||
|
let tree = {
|
||||||
|
let mut tree = resvg::usvg::Tree::from_data(
|
||||||
|
&raw_data,
|
||||||
|
&resvg::usvg::Options {
|
||||||
|
dpi,
|
||||||
|
font_family: "Inter".to_owned(),
|
||||||
|
font_size: 11.0,
|
||||||
|
text_rendering: TextRendering::OptimizeLegibility,
|
||||||
|
..resvg::usvg::Options::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tree.convert_text(font_db);
|
||||||
|
|
||||||
|
resvg::Tree::from_usvg(&tree)
|
||||||
|
};
|
||||||
|
|
||||||
|
let size = tree.size.to_int_size();
|
||||||
|
let mut pixmap = Pixmap::new((size.width() as f32 * scale).ceil() as u32, (size.height() as f32 * scale).ceil() as u32).unwrap();
|
||||||
|
|
||||||
|
tree.render(Transform::from_scale(scale, scale), &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
Ok(pixmap)
|
||||||
|
}
|
|
@ -11,14 +11,13 @@ use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::model::config::WithFallbackId;
|
use crate::model::config::WithFallbackId;
|
||||||
|
|
||||||
|
mod handler_host;
|
||||||
mod icons;
|
mod icons;
|
||||||
mod model;
|
mod model;
|
||||||
mod modes;
|
|
||||||
mod runner;
|
mod runner;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "deckster")]
|
#[command(name = "deckster", about = "Use Loupedeck devices under Linux.")]
|
||||||
#[command(about = "Use Loupedeck devices under Linux.")]
|
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
|
@ -1,5 +1,6 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use color_eyre::{eyre::eyre, Result};
|
use color_eyre::{eyre::eyre, Result};
|
||||||
use enum_map::EnumMap;
|
use enum_map::EnumMap;
|
||||||
|
@ -19,7 +20,7 @@ pub struct File {
|
||||||
#[serde(default = "inactive_button_color_default")]
|
#[serde(default = "inactive_button_color_default")]
|
||||||
pub inactive_button_color: RGB8Wrapper,
|
pub inactive_button_color: RGB8Wrapper,
|
||||||
pub label_font_family: Option<String>,
|
pub label_font_family: Option<String>,
|
||||||
pub icon_packs: HashMap<String, IconPack>,
|
pub icon_packs: Arc<HashMap<String, IconPack>>,
|
||||||
pub buttons: HashMap<ButtonPosition, ButtonConfig>, // EnumMap
|
pub buttons: HashMap<ButtonPosition, ButtonConfig>, // EnumMap
|
||||||
pub initial: InitialConfig,
|
pub initial: InitialConfig,
|
||||||
}
|
}
|
||||||
|
@ -37,7 +38,7 @@ pub struct Config {
|
||||||
pub label_font_family: Option<String>,
|
pub label_font_family: Option<String>,
|
||||||
pub key_pages_by_id: HashMap<String, model::key_page::Page>,
|
pub key_pages_by_id: HashMap<String, model::key_page::Page>,
|
||||||
pub knob_pages_by_id: HashMap<String, model::knob_page::Page>,
|
pub knob_pages_by_id: HashMap<String, model::knob_page::Page>,
|
||||||
pub icon_packs: HashMap<String, IconPack>,
|
pub icon_packs: Arc<HashMap<String, IconPack>>,
|
||||||
pub buttons: EnumMap<ButtonPosition, ButtonConfig>,
|
pub buttons: EnumMap<ButtonPosition, ButtonConfig>,
|
||||||
pub initial: InitialConfig,
|
pub initial: InitialConfig,
|
||||||
}
|
}
|
|
@ -7,7 +7,6 @@ use deckster_shared::path::{KeyPosition, KnobPosition};
|
||||||
use deckster_shared::style::KeyStyle;
|
use deckster_shared::style::KeyStyle;
|
||||||
|
|
||||||
use crate::model::geometry::UIntVec2;
|
use crate::model::geometry::UIntVec2;
|
||||||
use crate::modes;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct File {
|
pub struct File {
|
||||||
|
@ -51,17 +50,6 @@ pub struct Key {
|
||||||
#[serde(default, flatten)]
|
#[serde(default, flatten)]
|
||||||
pub base_style: KeyStyle,
|
pub base_style: KeyStyle,
|
||||||
|
|
||||||
#[serde(default)]
|
pub handler: String,
|
||||||
pub mode: KeyModes,
|
pub config: Arc<toml::Table>,
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct KeyModes {
|
|
||||||
pub command: Option<Arc<modes::key::command::Config>>,
|
|
||||||
pub home_assistant__switch: Option<Arc<modes::key::home_assistant::SwitchConfig>>,
|
|
||||||
pub home_assistant__button: Option<Arc<modes::key::home_assistant::ButtonConfig>>,
|
|
||||||
pub playerctl__button: Option<Arc<modes::key::playerctl::ButtonConfig>>,
|
|
||||||
pub playerctl__shuffle: Option<Arc<modes::key::playerctl::ShuffleConfig>>,
|
|
||||||
pub playerctl__loop: Option<Arc<modes::key::playerctl::LoopConfig>>,
|
|
||||||
}
|
}
|
|
@ -7,8 +7,6 @@ use serde::Deserialize;
|
||||||
use deckster_shared::path::KnobPosition;
|
use deckster_shared::path::KnobPosition;
|
||||||
use deckster_shared::style::KnobStyle;
|
use deckster_shared::style::KnobStyle;
|
||||||
|
|
||||||
use crate::modes;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct File {
|
pub struct File {
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
|
@ -25,11 +23,7 @@ pub struct Page {
|
||||||
pub struct Knob {
|
pub struct Knob {
|
||||||
#[serde(default, flatten)]
|
#[serde(default, flatten)]
|
||||||
pub base_style: KnobStyle,
|
pub base_style: KnobStyle,
|
||||||
#[serde(default)]
|
|
||||||
pub mode: KnobModes,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
pub handler: String,
|
||||||
pub struct KnobModes {
|
pub config: Arc<toml::Table>,
|
||||||
pub audio_volume: Option<Arc<modes::knob::audio_volume::Config>>,
|
|
||||||
}
|
}
|
|
@ -1,24 +1,22 @@
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use bytes::{BufMut, Bytes, BytesMut};
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
use log::error;
|
||||||
use resvg::usvg::tiny_skia_path::PathBuilder;
|
use resvg::usvg::tiny_skia_path::PathBuilder;
|
||||||
use rgb::RGBA;
|
use rgb::RGBA;
|
||||||
use tiny_skia::{Color, IntSize, LineCap, LineJoin, Paint, Pixmap, PremultipliedColorU8, Rect, Shader, Stroke, Transform};
|
use tiny_skia::{Color, IntSize, LineCap, LineJoin, Paint, Pixmap, PremultipliedColorU8, Rect, Shader, Stroke, Transform};
|
||||||
|
|
||||||
use deckster_shared::image_filter::ImageFilter;
|
|
||||||
use deckster_shared::state::{Key, Knob};
|
use deckster_shared::state::{Key, Knob};
|
||||||
use loupedeck_serial::util::Endianness;
|
use loupedeck_serial::util::Endianness;
|
||||||
|
|
||||||
use crate::icons::{render_icon_in, LoadedIconsMap};
|
use crate::icons::IconManager;
|
||||||
use crate::runner::graphics::labels::LabelRenderer;
|
use crate::runner::graphics::labels::LabelRenderer;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct GraphicsContext {
|
pub struct GraphicsContext {
|
||||||
pub label_renderer: RefCell<LabelRenderer>,
|
pub label_renderer: RefCell<LabelRenderer>,
|
||||||
pub buffer_endianness: Endianness,
|
pub buffer_endianness: Endianness,
|
||||||
pub global_icon_filter_by_pack_id: HashMap<String, ImageFilter>,
|
pub icon_manager: IconManager,
|
||||||
pub loaded_icons: LoadedIconsMap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_key(context: &GraphicsContext, key_size: IntSize, state: Option<&Key>) -> Bytes {
|
pub fn render_key(context: &GraphicsContext, key_size: IntSize, state: Option<&Key>) -> Bytes {
|
||||||
|
@ -29,7 +27,9 @@ pub fn render_key(context: &GraphicsContext, key_size: IntSize, state: Option<&K
|
||||||
let style = style.as_ref().unwrap_or(&state.base_style);
|
let style = style.as_ref().unwrap_or(&state.base_style);
|
||||||
|
|
||||||
if let Some(icon) = &style.icon {
|
if let Some(icon) = &style.icon {
|
||||||
render_icon_in(&mut pixmap, &context.global_icon_filter_by_pack_id, &context.loaded_icons, icon);
|
if let Err(e) = context.icon_manager.render_icon_in(&mut pixmap, icon) {
|
||||||
|
error!("Failed to render key: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(label) = &style.label {
|
if let Some(label) = &style.label {
|
||||||
|
@ -69,7 +69,9 @@ pub fn render_knob(context: &GraphicsContext, screen_size: IntSize, state: Optio
|
||||||
let style = style.as_ref().unwrap_or(&state.base_style);
|
let style = style.as_ref().unwrap_or(&state.base_style);
|
||||||
|
|
||||||
if let Some(icon) = &style.icon {
|
if let Some(icon) = &style.icon {
|
||||||
render_icon_in(&mut pixmap, &context.global_icon_filter_by_pack_id, &context.loaded_icons, icon);
|
if let Err(e) = context.icon_manager.render_icon_in(&mut pixmap, icon) {
|
||||||
|
error!("Failed to render knob: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(label) = &style.label {
|
if let Some(label) = &style.label {
|
|
@ -2,7 +2,6 @@ use std::cell::RefCell;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use color_eyre::eyre::{ContextCompat, WrapErr};
|
use color_eyre::eyre::{ContextCompat, WrapErr};
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
|
@ -10,10 +9,9 @@ use enum_ordinalize::Ordinalize;
|
||||||
use log::{error, info, trace};
|
use log::{error, info, trace};
|
||||||
use rgb::RGB8;
|
use rgb::RGB8;
|
||||||
use tiny_skia::IntSize;
|
use tiny_skia::IntSize;
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
use deckster_shared::handler_communication::HandlerCommand;
|
|
||||||
use deckster_shared::handler_communication::KnobEvent;
|
use deckster_shared::handler_communication::KnobEvent;
|
||||||
|
use deckster_shared::handler_communication::{HandlerCommand, HandlerEvent, KeyEvent, KeyTouchEventKind};
|
||||||
use deckster_shared::path::{KeyPath, KeyPosition, KnobPath, KnobPosition};
|
use deckster_shared::path::{KeyPath, KeyPosition, KnobPath, KnobPosition};
|
||||||
use deckster_shared::state::{Key, Knob};
|
use deckster_shared::state::{Key, Knob};
|
||||||
use loupedeck_serial::characteristics::{LoupedeckButton, LoupedeckDeviceKeyGridCharacteristics, LoupedeckDisplayRect, LoupedeckKnob};
|
use loupedeck_serial::characteristics::{LoupedeckButton, LoupedeckDeviceKeyGridCharacteristics, LoupedeckDisplayRect, LoupedeckKnob};
|
||||||
|
@ -21,43 +19,103 @@ use loupedeck_serial::commands::VibrationPattern;
|
||||||
use loupedeck_serial::device::LoupedeckDevice;
|
use loupedeck_serial::device::LoupedeckDevice;
|
||||||
use loupedeck_serial::events::{LoupedeckEvent, RotationDirection};
|
use loupedeck_serial::events::{LoupedeckEvent, RotationDirection};
|
||||||
|
|
||||||
use crate::icons::{get_used_icon_descriptors, load_icons, LoadedIconsMap};
|
use crate::handler_host;
|
||||||
|
use crate::handler_host::KeyOrKnobConfig;
|
||||||
|
use crate::icons::IconManager;
|
||||||
|
use crate::model::config::Config;
|
||||||
use crate::model::position::ButtonPosition;
|
use crate::model::position::ButtonPosition;
|
||||||
use crate::modes::key::{KeyEvent, KeyTouchEventKind};
|
|
||||||
use crate::runner::graphics::labels::LabelRenderer;
|
use crate::runner::graphics::labels::LabelRenderer;
|
||||||
use crate::runner::graphics::{render_key, render_knob, GraphicsContext};
|
use crate::runner::graphics::{render_key, render_knob, GraphicsContext};
|
||||||
use crate::runner::state::State;
|
use crate::runner::state::State;
|
||||||
use crate::{model, modes};
|
|
||||||
|
|
||||||
mod graphics;
|
mod graphics;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub async fn start(config_directory: &Path, config: model::config::Config) -> Result<()> {
|
pub async fn start(config_directory: &Path, config: Config) -> Result<()> {
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
info!("Discovering devices…");
|
info!("Discovering devices…");
|
||||||
|
|
||||||
let available_devices = LoupedeckDevice::discover()?;
|
let available_devices = LoupedeckDevice::discover()?;
|
||||||
let available_device = available_devices.first().wrap_err("No device connected.")?;
|
let available_device = available_devices.first().wrap_err("No device connected.")?;
|
||||||
|
info!("Found {} device(s).", available_devices.len());
|
||||||
|
|
||||||
|
let (commands_sender, commands_receiver) = flume::bounded::<HandlerCommand>(5);
|
||||||
|
let (events_sender, events_receiver) = flume::bounded::<HandlerEvent>(5);
|
||||||
|
|
||||||
|
let key_configs = config
|
||||||
|
.key_pages_by_id
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(page_id, p)| {
|
||||||
|
p.keys.iter().map(|(position, k)| {
|
||||||
|
let (handler_name, mode_string) = k
|
||||||
|
.handler
|
||||||
|
.split_once(' ')
|
||||||
|
.map(|(a, b)| (a.into(), b.into()))
|
||||||
|
.unwrap_or_else(|| (k.handler.as_str().into(), "".into()));
|
||||||
|
|
||||||
|
(
|
||||||
|
KeyPath {
|
||||||
|
page_id: page_id.clone(),
|
||||||
|
position: *position,
|
||||||
|
},
|
||||||
|
KeyOrKnobConfig {
|
||||||
|
handler_name,
|
||||||
|
mode_string,
|
||||||
|
handler_config: Arc::clone(&k.config),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let knob_configs = config
|
||||||
|
.knob_pages_by_id
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(page_id, p)| {
|
||||||
|
p.knobs.iter().filter_map(|(position, k)| {
|
||||||
|
if k.handler.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (handler_name, mode_string) = k
|
||||||
|
.handler
|
||||||
|
.split_once(' ')
|
||||||
|
.map(|(a, b)| (a.into(), b.into()))
|
||||||
|
.unwrap_or_else(|| (k.handler.as_str().into(), "".into()));
|
||||||
|
|
||||||
|
Some((
|
||||||
|
KnobPath {
|
||||||
|
page_id: page_id.clone(),
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
KeyOrKnobConfig {
|
||||||
|
handler_name,
|
||||||
|
mode_string,
|
||||||
|
handler_config: Arc::clone(&k.config),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("Initializing handler processes…");
|
||||||
|
|
||||||
|
handler_host::start(
|
||||||
|
&config_directory.join("handlers"),
|
||||||
|
key_configs,
|
||||||
|
knob_configs,
|
||||||
|
commands_sender.clone(),
|
||||||
|
events_receiver,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
info!("Connecting to the device…");
|
info!("Connecting to the device…");
|
||||||
let device = available_device.connect().wrap_err("Connecting to the device failed.")?;
|
let device = available_device.connect().wrap_err("Connecting to the device failed.")?;
|
||||||
info!("Connected.");
|
info!("Connected.");
|
||||||
|
|
||||||
let key_grid = &device.characteristics().key_grid;
|
|
||||||
let used_icon_descriptors = get_used_icon_descriptors(&config);
|
|
||||||
|
|
||||||
let start_time = Instant::now();
|
|
||||||
info!("Loading icons…");
|
|
||||||
let icons = load_icons(config_directory, &config.icon_packs, used_icon_descriptors, key_grid.display.dpi)?;
|
|
||||||
info!("Finished loading {} icon(s) in {}ms", icons.len(), start_time.elapsed().as_millis());
|
|
||||||
|
|
||||||
device.set_brightness(0.1);
|
device.set_brightness(0.1);
|
||||||
device.vibrate(VibrationPattern::RiseFall);
|
device.vibrate(VibrationPattern::RiseFall);
|
||||||
|
|
||||||
let (commands_sender, commands_receiver) = flume::bounded::<HandlerCommand>(5);
|
|
||||||
let key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)> = broadcast::Sender::new(5);
|
|
||||||
let knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)> = broadcast::Sender::new(5);
|
|
||||||
|
|
||||||
commands_sender
|
commands_sender
|
||||||
.send(HandlerCommand::SetActivePages {
|
.send(HandlerCommand::SetActivePages {
|
||||||
knob_page_id: config.initial.knob_page.clone(),
|
knob_page_id: config.initial.knob_page.clone(),
|
||||||
|
@ -65,14 +123,7 @@ pub async fn start(config_directory: &Path, config: model::config::Config) -> Re
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let io_worker_context = IoWorkerContext::create(
|
let io_worker_context = IoWorkerContext::create(config_directory, Arc::clone(&config), device, commands_sender.clone(), events_sender);
|
||||||
Arc::clone(&config),
|
|
||||||
icons,
|
|
||||||
device,
|
|
||||||
commands_sender.clone(),
|
|
||||||
key_events_sender.clone(),
|
|
||||||
knob_events_sender.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let io_worker_thread = thread::Builder::new()
|
let io_worker_thread = thread::Builder::new()
|
||||||
.name("deckster IO worker".to_owned())
|
.name("deckster IO worker".to_owned())
|
||||||
|
@ -81,38 +132,6 @@ pub async fn start(config_directory: &Path, config: model::config::Config) -> Re
|
||||||
})
|
})
|
||||||
.wrap_err("Could not spawn the worker thread")?;
|
.wrap_err("Could not spawn the worker thread")?;
|
||||||
|
|
||||||
modes::key::start_handlers(
|
|
||||||
config.key_pages_by_id.iter().flat_map(|(page_id, page)| {
|
|
||||||
page.keys.iter().map(|(position, key)| {
|
|
||||||
(
|
|
||||||
KeyPath {
|
|
||||||
page_id: page_id.clone(),
|
|
||||||
position: *position,
|
|
||||||
},
|
|
||||||
Arc::clone(key),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
key_events_sender,
|
|
||||||
commands_sender.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
modes::knob::start_handlers(
|
|
||||||
config.knob_pages_by_id.iter().flat_map(|(page_id, page)| {
|
|
||||||
page.knobs.iter().map(|(position, knob)| {
|
|
||||||
(
|
|
||||||
KnobPath {
|
|
||||||
page_id: page_id.clone(),
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
Arc::clone(knob),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
knob_events_sender,
|
|
||||||
commands_sender,
|
|
||||||
);
|
|
||||||
|
|
||||||
info!("Ready.");
|
info!("Ready.");
|
||||||
io_worker_thread.join().unwrap();
|
io_worker_thread.join().unwrap();
|
||||||
|
|
||||||
|
@ -120,48 +139,40 @@ pub async fn start(config_directory: &Path, config: model::config::Config) -> Re
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IoWork {
|
enum IoWork {
|
||||||
Event(LoupedeckEvent),
|
DeviceEvent(LoupedeckEvent),
|
||||||
Command(HandlerCommand),
|
Command(HandlerCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IoWorkerContext {
|
struct IoWorkerContext {
|
||||||
config: Arc<model::config::Config>,
|
config: Arc<Config>,
|
||||||
device: LoupedeckDevice,
|
device: LoupedeckDevice,
|
||||||
commands_sender: flume::Sender<HandlerCommand>,
|
commands_sender: flume::Sender<HandlerCommand>,
|
||||||
key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>,
|
events_sender: flume::Sender<HandlerEvent>,
|
||||||
knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>,
|
|
||||||
graphics: GraphicsContext,
|
graphics: GraphicsContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IoWorkerContext {
|
impl IoWorkerContext {
|
||||||
pub fn create(
|
pub fn create(
|
||||||
config: Arc<model::config::Config>,
|
config_directory: &Path,
|
||||||
icons: LoadedIconsMap,
|
config: Arc<Config>,
|
||||||
device: LoupedeckDevice,
|
device: LoupedeckDevice,
|
||||||
commands_sender: flume::Sender<HandlerCommand>,
|
commands_sender: flume::Sender<HandlerCommand>,
|
||||||
key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>,
|
events_sender: flume::Sender<HandlerEvent>,
|
||||||
knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let buffer_endianness = device.characteristics().key_grid.display.endianness;
|
let buffer_endianness = device.characteristics().key_grid.display.endianness;
|
||||||
let global_icon_filter_by_pack_id = config
|
|
||||||
.icon_packs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(i, p)| p.global_filter.clone().map(|f| (i.clone(), f)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let label_renderer = RefCell::new(LabelRenderer::new(config.label_font_family.as_ref()));
|
let label_renderer = RefCell::new(LabelRenderer::new(config.label_font_family.as_ref()));
|
||||||
|
let dpi = device.characteristics().key_grid.display.dpi;
|
||||||
|
let icon_packs = Arc::clone(&config.icon_packs);
|
||||||
|
|
||||||
IoWorkerContext {
|
IoWorkerContext {
|
||||||
config,
|
config,
|
||||||
device,
|
device,
|
||||||
commands_sender,
|
commands_sender,
|
||||||
key_events_sender,
|
events_sender,
|
||||||
knob_events_sender,
|
|
||||||
graphics: GraphicsContext {
|
graphics: GraphicsContext {
|
||||||
loaded_icons: icons,
|
|
||||||
buffer_endianness,
|
buffer_endianness,
|
||||||
label_renderer,
|
label_renderer,
|
||||||
global_icon_filter_by_pack_id,
|
icon_manager: IconManager::new(config_directory.to_path_buf(), icon_packs, dpi),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -173,12 +184,12 @@ fn do_io_work(context: IoWorkerContext, commands_receiver: flume::Receiver<Handl
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let a = flume::Selector::new()
|
let a = flume::Selector::new()
|
||||||
.recv(&device_events_receiver, |e| IoWork::Event(e.unwrap()))
|
.recv(&device_events_receiver, |e| IoWork::DeviceEvent(e.unwrap()))
|
||||||
.recv(&commands_receiver, |c| IoWork::Command(c.unwrap()))
|
.recv(&commands_receiver, |c| IoWork::Command(c.unwrap()))
|
||||||
.wait();
|
.wait();
|
||||||
|
|
||||||
match a {
|
match a {
|
||||||
IoWork::Event(event) => {
|
IoWork::DeviceEvent(event) => {
|
||||||
if !handle_event(&context, &mut state, event) {
|
if !handle_event(&context, &mut state, event) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -193,12 +204,12 @@ fn handle_event(context: &IoWorkerContext, state: &mut State, event: LoupedeckEv
|
||||||
|
|
||||||
let send_key_event = |path: KeyPath, event: KeyEvent| {
|
let send_key_event = |path: KeyPath, event: KeyEvent| {
|
||||||
trace!("Sending key event ({}): {:?}", &path, &event);
|
trace!("Sending key event ({}): {:?}", &path, &event);
|
||||||
context.key_events_sender.send((path, event)).unwrap();
|
context.events_sender.send(HandlerEvent::Key { path, event }).unwrap();
|
||||||
};
|
};
|
||||||
|
|
||||||
let send_knob_event = |path: KnobPath, event: KnobEvent| {
|
let send_knob_event = |path: KnobPath, event: KnobEvent| {
|
||||||
trace!("Sending knob event ({:?}): {:?}", &path, &event);
|
trace!("Sending knob event ({:?}): {:?}", &path, &event);
|
||||||
context.knob_events_sender.send((path, event)).unwrap();
|
context.events_sender.send(HandlerEvent::Knob { path, event }).unwrap();
|
||||||
};
|
};
|
||||||
|
|
||||||
match event {
|
match event {
|