This commit is contained in:
Moritz Ruth 2024-01-31 01:23:56 +01:00
parent 1904e3e96a
commit b5a7ab3c6b
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
71 changed files with 921 additions and 1297 deletions

91
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"

View 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(())
}

View file

@ -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"

View file

@ -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> },
}

View 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,
}

View file

@ -1,5 +1,5 @@
[package]
name = "pa-volume-interface"
name = "pa_volume_interface"
version = "0.1.0"
edition = "2021"

View file

@ -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"

View file

@ -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,
);
}

View file

@ -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),
}
}
}
}

View file

@ -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,
}

View file

@ -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;
}
}
}
});
}
}

View file

@ -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)
}
}
}
}
}
}

View file

@ -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);

View file

@ -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)
}
}
_ => {}
}
}
}
}
}
}

View file

@ -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()));
}
}
}

View file

@ -1,2 +0,0 @@
pub mod key;
pub mod knob;

View file

@ -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(())
}

View file

@ -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,
}

View file

@ -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

Binary file not shown.

View file

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 764 B

View file

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 679 B

View file

Before

Width:  |  Height:  |  Size: 441 B

After

Width:  |  Height:  |  Size: 441 B

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 532 B

After

Width:  |  Height:  |  Size: 532 B

View file

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View file

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 384 B

View file

Before

Width:  |  Height:  |  Size: 649 B

After

Width:  |  Height:  |  Size: 649 B

View file

Before

Width:  |  Height:  |  Size: 342 B

After

Width:  |  Height:  |  Size: 342 B

View file

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 402 B

View file

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

View file

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View file

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

View file

Before

Width:  |  Height:  |  Size: 364 B

After

Width:  |  Height:  |  Size: 364 B

View file

@ -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"

View 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"

View 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);
}
}

View 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
View 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
View 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)
}

View file

@ -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,

View file

@ -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,
}

View file

@ -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>,
}

View file

@ -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>,
}

View file

@ -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 {

View file

@ -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 {