commit
91
Cargo.lock
generated
|
@ -76,7 +76,7 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -86,7 +86,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"windows-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -373,15 +373,15 @@ dependencies = [
|
|||
"env_logger",
|
||||
"flume",
|
||||
"humantime-serde",
|
||||
"is_executable",
|
||||
"log",
|
||||
"loupedeck_serial",
|
||||
"once_cell",
|
||||
"pa-volume-interface",
|
||||
"parse-display",
|
||||
"regex",
|
||||
"resvg",
|
||||
"rgb",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_regex",
|
||||
"serde_with",
|
||||
"thiserror",
|
||||
|
@ -398,8 +398,9 @@ dependencies = [
|
|||
"deckster_shared",
|
||||
"either",
|
||||
"im",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -410,11 +411,11 @@ dependencies = [
|
|||
"enum-map",
|
||||
"enum-ordinalize",
|
||||
"im",
|
||||
"parse-display",
|
||||
"rgb",
|
||||
"serde",
|
||||
"serde_with",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -525,9 +526,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eeb342678d785662fd2514be38c459bb925f02b68dd2a3e0f21d7ef82d979dd"
|
||||
checksum = "05e7cf40684ae96ade6232ed84582f40ce0a66efcd43a5117aef610534f8e0b8"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
@ -807,6 +808,15 @@ dependencies = [
|
|||
"mach2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_executable"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.10"
|
||||
|
@ -975,6 +985,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
|
@ -1047,7 +1068,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
|
||||
|
||||
[[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"
|
||||
dependencies = [
|
||||
"flume",
|
||||
|
@ -1361,18 +1399,18 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0"
|
|||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.195"
|
||||
version = "1.0.196"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
|
||||
checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.195"
|
||||
version = "1.0.196"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
|
||||
checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -1381,9 +1419,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.111"
|
||||
version = "1.0.113"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4"
|
||||
checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
|
@ -1466,6 +1504,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
|
@ -1717,10 +1764,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2085,6 +2137,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-sys"
|
||||
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]
|
||||
members = [
|
||||
"deckster",
|
||||
"deckster_mode",
|
||||
"deckster_shared",
|
||||
"loupedeck_serial",
|
||||
"pa-volume-interface"
|
||||
"crates/*",
|
||||
"handlers/*"
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
|
|
@ -7,5 +7,6 @@ edition = "2021"
|
|||
deckster_shared = { path = "../deckster_shared" }
|
||||
thiserror = "1.0.56"
|
||||
im = "15.1.0"
|
||||
toml = "0.8.8"
|
||||
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-map = "3.0.0-beta.2"
|
||||
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 thiserror::Error;
|
||||
|
||||
use crate::path::{KeyPath, KnobPath};
|
||||
use crate::style::{KeyStyle, KnobStyle};
|
||||
|
@ -50,6 +51,27 @@ pub enum HandlerCommand {
|
|||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub struct InitialHandlerMessage<KeyConfig: Clone, KnobConfig: Clone> {
|
||||
key_configs: im::HashMap<KeyPath, (String, KeyConfig)>,
|
||||
knob_configs: im::HashMap<KnobPath, (String, KnobConfig)>,
|
||||
pub key_configs: im::HashMap<KeyPath, (Box<str>, KeyConfig)>,
|
||||
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]
|
||||
name = "pa-volume-interface"
|
||||
name = "pa_volume_interface"
|
||||
version = "0.1.0"
|
||||
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"
|
||||
active_button_color = "#eeffff"
|
||||
label_font_family = "Inter"
|
||||
|
||||
[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"
|
||||
buttons = { }
|
||||
|
||||
[initial]
|
||||
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]"
|
||||
indicators.bar.color = "#ffffff50"
|
||||
|
||||
handler = "audio_volume"
|
||||
handler = "pa_volume"
|
||||
config.delta = 0.05
|
||||
config.target.type = "input"
|
||||
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]"
|
||||
indicators.bar.color = "#ffffff50"
|
||||
|
||||
handler = "audio_volume"
|
||||
handler = "pa_volume"
|
||||
config.delta = 0.05
|
||||
config.target.type = "application"
|
||||
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]"
|
||||
indicators.bar.color = "#ffffff50"
|
||||
|
||||
handler = "audio_volume"
|
||||
handler = "pa_volume"
|
||||
config.delta = 0.05
|
||||
config.muted_turn_action = "unmute"
|
||||
config.target.type = "application"
|
||||
|
@ -44,7 +44,7 @@ config.style.inactive.icon = "@apps/youtube[scale=1.3|grayscale]"
|
|||
icon = "@apps/spotify[scale=1.2]"
|
||||
indicators.bar.color = "#ffffff50"
|
||||
|
||||
handler = "audio_volume"
|
||||
handler = "pa_volume"
|
||||
config.delta = 0.05
|
||||
config.muted_turn_action = "unmute-at-zero"
|
||||
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;
|
||||
|
||||
mod handler_host;
|
||||
mod icons;
|
||||
mod model;
|
||||
mod modes;
|
||||
mod runner;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "deckster")]
|
||||
#[command(about = "Use Loupedeck devices under Linux.")]
|
||||
#[command(name = "deckster", about = "Use Loupedeck devices under Linux.")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
|
@ -1,5 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::{eyre::eyre, Result};
|
||||
use enum_map::EnumMap;
|
||||
|
@ -19,7 +20,7 @@ pub struct File {
|
|||
#[serde(default = "inactive_button_color_default")]
|
||||
pub inactive_button_color: RGB8Wrapper,
|
||||
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 initial: InitialConfig,
|
||||
}
|
||||
|
@ -37,7 +38,7 @@ pub struct Config {
|
|||
pub label_font_family: Option<String>,
|
||||
pub key_pages_by_id: HashMap<String, model::key_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 initial: InitialConfig,
|
||||
}
|
|
@ -7,7 +7,6 @@ use deckster_shared::path::{KeyPosition, KnobPosition};
|
|||
use deckster_shared::style::KeyStyle;
|
||||
|
||||
use crate::model::geometry::UIntVec2;
|
||||
use crate::modes;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct File {
|
||||
|
@ -51,17 +50,6 @@ pub struct Key {
|
|||
#[serde(default, flatten)]
|
||||
pub base_style: KeyStyle,
|
||||
|
||||
#[serde(default)]
|
||||
pub mode: KeyModes,
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
pub handler: String,
|
||||
pub config: Arc<toml::Table>,
|
||||
}
|
|
@ -7,8 +7,6 @@ use serde::Deserialize;
|
|||
use deckster_shared::path::KnobPosition;
|
||||
use deckster_shared::style::KnobStyle;
|
||||
|
||||
use crate::modes;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct File {
|
||||
pub id: Option<String>,
|
||||
|
@ -25,11 +23,7 @@ pub struct Page {
|
|||
pub struct Knob {
|
||||
#[serde(default, flatten)]
|
||||
pub base_style: KnobStyle,
|
||||
#[serde(default)]
|
||||
pub mode: KnobModes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct KnobModes {
|
||||
pub audio_volume: Option<Arc<modes::knob::audio_volume::Config>>,
|
||||
pub handler: String,
|
||||
pub config: Arc<toml::Table>,
|
||||
}
|
|
@ -1,24 +1,22 @@
|
|||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use log::error;
|
||||
use resvg::usvg::tiny_skia_path::PathBuilder;
|
||||
use rgb::RGBA;
|
||||
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 loupedeck_serial::util::Endianness;
|
||||
|
||||
use crate::icons::{render_icon_in, LoadedIconsMap};
|
||||
use crate::icons::IconManager;
|
||||
use crate::runner::graphics::labels::LabelRenderer;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GraphicsContext {
|
||||
pub label_renderer: RefCell<LabelRenderer>,
|
||||
pub buffer_endianness: Endianness,
|
||||
pub global_icon_filter_by_pack_id: HashMap<String, ImageFilter>,
|
||||
pub loaded_icons: LoadedIconsMap,
|
||||
pub icon_manager: IconManager,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 {
|
||||
|
@ -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);
|
||||
|
||||
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 {
|
|
@ -2,7 +2,6 @@ use std::cell::RefCell;
|
|||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use color_eyre::eyre::{ContextCompat, WrapErr};
|
||||
use color_eyre::Result;
|
||||
|
@ -10,10 +9,9 @@ use enum_ordinalize::Ordinalize;
|
|||
use log::{error, info, trace};
|
||||
use rgb::RGB8;
|
||||
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::{HandlerCommand, HandlerEvent, KeyEvent, KeyTouchEventKind};
|
||||
use deckster_shared::path::{KeyPath, KeyPosition, KnobPath, KnobPosition};
|
||||
use deckster_shared::state::{Key, Knob};
|
||||
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::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::modes::key::{KeyEvent, KeyTouchEventKind};
|
||||
use crate::runner::graphics::labels::LabelRenderer;
|
||||
use crate::runner::graphics::{render_key, render_knob, GraphicsContext};
|
||||
use crate::runner::state::State;
|
||||
use crate::{model, modes};
|
||||
|
||||
mod graphics;
|
||||
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);
|
||||
info!("Discovering devices…");
|
||||
|
||||
let available_devices = LoupedeckDevice::discover()?;
|
||||
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…");
|
||||
let device = available_device.connect().wrap_err("Connecting to the device failed.")?;
|
||||
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.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
|
||||
.send(HandlerCommand::SetActivePages {
|
||||
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();
|
||||
|
||||
let io_worker_context = IoWorkerContext::create(
|
||||
Arc::clone(&config),
|
||||
icons,
|
||||
device,
|
||||
commands_sender.clone(),
|
||||
key_events_sender.clone(),
|
||||
knob_events_sender.clone(),
|
||||
);
|
||||
let io_worker_context = IoWorkerContext::create(config_directory, Arc::clone(&config), device, commands_sender.clone(), events_sender);
|
||||
|
||||
let io_worker_thread = thread::Builder::new()
|
||||
.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")?;
|
||||
|
||||
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.");
|
||||
io_worker_thread.join().unwrap();
|
||||
|
||||
|
@ -120,48 +139,40 @@ pub async fn start(config_directory: &Path, config: model::config::Config) -> Re
|
|||
}
|
||||
|
||||
enum IoWork {
|
||||
Event(LoupedeckEvent),
|
||||
DeviceEvent(LoupedeckEvent),
|
||||
Command(HandlerCommand),
|
||||
}
|
||||
|
||||
struct IoWorkerContext {
|
||||
config: Arc<model::config::Config>,
|
||||
config: Arc<Config>,
|
||||
device: LoupedeckDevice,
|
||||
commands_sender: flume::Sender<HandlerCommand>,
|
||||
key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>,
|
||||
knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>,
|
||||
events_sender: flume::Sender<HandlerEvent>,
|
||||
graphics: GraphicsContext,
|
||||
}
|
||||
|
||||
impl IoWorkerContext {
|
||||
pub fn create(
|
||||
config: Arc<model::config::Config>,
|
||||
icons: LoadedIconsMap,
|
||||
config_directory: &Path,
|
||||
config: Arc<Config>,
|
||||
device: LoupedeckDevice,
|
||||
commands_sender: flume::Sender<HandlerCommand>,
|
||||
key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>,
|
||||
knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>,
|
||||
events_sender: flume::Sender<HandlerEvent>,
|
||||
) -> Self {
|
||||
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 dpi = device.characteristics().key_grid.display.dpi;
|
||||
let icon_packs = Arc::clone(&config.icon_packs);
|
||||
|
||||
IoWorkerContext {
|
||||
config,
|
||||
device,
|
||||
commands_sender,
|
||||
key_events_sender,
|
||||
knob_events_sender,
|
||||
events_sender,
|
||||
graphics: GraphicsContext {
|
||||
loaded_icons: icons,
|
||||
buffer_endianness,
|
||||
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 {
|
||||
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()))
|
||||
.wait();
|
||||
|
||||
match a {
|
||||
IoWork::Event(event) => {
|
||||
IoWork::DeviceEvent(event) => {
|
||||
if !handle_event(&context, &mut state, event) {
|
||||
break;
|
||||
}
|
||||
|
@ -193,12 +204,12 @@ fn handle_event(context: &IoWorkerContext, state: &mut State, event: LoupedeckEv
|
|||
|
||||
let send_key_event = |path: KeyPath, event: KeyEvent| {
|
||||
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| {
|
||||
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 {
|