diff --git a/Cargo.lock b/Cargo.lock index 1091078..7fac143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 15cd9d8..fff62ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,45 @@ +[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" [profile.release] -strip = true # Automatically strip symbols from the binary. \ No newline at end of file +strip = true # Automatically strip symbols from the binary. diff --git a/deckster_mode/Cargo.toml b/crates/deckster_mode/Cargo.toml similarity index 63% rename from deckster_mode/Cargo.toml rename to crates/deckster_mode/Cargo.toml index 2c626d6..821d97a 100644 --- a/deckster_mode/Cargo.toml +++ b/crates/deckster_mode/Cargo.toml @@ -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" \ No newline at end of file +either = "1.9.0" +serde = { version = "1.0.196", default-features = false } +serde_json = "1.0.113" diff --git a/crates/deckster_mode/src/lib.rs b/crates/deckster_mode/src/lib.rs new file mode 100644 index 0000000..1edb992 --- /dev/null +++ b/crates/deckster_mode/src/lib.rs @@ -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) -> Result, +>( + init_handler: I, +) -> Result<(), RunError> { + let mut handler: Either = Either::Right(init_handler); + + let supports_keys = TypeId::of::() != TypeId::of::<()>(); + let supports_knobs = TypeId::of::() != 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::>(&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(()) +} diff --git a/deckster_shared/Cargo.toml b/crates/deckster_shared/Cargo.toml similarity index 85% rename from deckster_shared/Cargo.toml rename to crates/deckster_shared/Cargo.toml index 46d642f..84032f2 100644 --- a/deckster_shared/Cargo.toml +++ b/crates/deckster_shared/Cargo.toml @@ -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 } \ No newline at end of file +parse-display = "0.8.2" diff --git a/deckster_shared/src/handler_communication.rs b/crates/deckster_shared/src/handler_communication.rs similarity index 65% rename from deckster_shared/src/handler_communication.rs rename to crates/deckster_shared/src/handler_communication.rs index 86f4ea0..5dd416f 100644 --- a/deckster_shared/src/handler_communication.rs +++ b/crates/deckster_shared/src/handler_communication.rs @@ -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 { - key_configs: im::HashMap, - knob_configs: im::HashMap, + pub key_configs: im::HashMap, KeyConfig)>, + pub knob_configs: im::HashMap, 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 }, + #[error("The provided handler config is invalid: {message}")] + InvalidConfig { + supports_keys: bool, + supports_knobs: bool, + message: Box, + }, + #[error("{message}")] + Other { message: Box }, } diff --git a/deckster_shared/src/icon_descriptor.rs b/crates/deckster_shared/src/icon_descriptor.rs similarity index 100% rename from deckster_shared/src/icon_descriptor.rs rename to crates/deckster_shared/src/icon_descriptor.rs diff --git a/deckster_shared/src/image_filter.rs b/crates/deckster_shared/src/image_filter.rs similarity index 100% rename from deckster_shared/src/image_filter.rs rename to crates/deckster_shared/src/image_filter.rs diff --git a/deckster_shared/src/lib.rs b/crates/deckster_shared/src/lib.rs similarity index 100% rename from deckster_shared/src/lib.rs rename to crates/deckster_shared/src/lib.rs diff --git a/crates/deckster_shared/src/path.rs b/crates/deckster_shared/src/path.rs new file mode 100644 index 0000000..517ea5a --- /dev/null +++ b/crates/deckster_shared/src/path.rs @@ -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, +} diff --git a/deckster_shared/src/rgb.rs b/crates/deckster_shared/src/rgb.rs similarity index 100% rename from deckster_shared/src/rgb.rs rename to crates/deckster_shared/src/rgb.rs diff --git a/deckster_shared/src/state.rs b/crates/deckster_shared/src/state.rs similarity index 100% rename from deckster_shared/src/state.rs rename to crates/deckster_shared/src/state.rs diff --git a/deckster_shared/src/style.rs b/crates/deckster_shared/src/style.rs similarity index 100% rename from deckster_shared/src/style.rs rename to crates/deckster_shared/src/style.rs diff --git a/loupedeck_serial/Cargo.toml b/crates/loupedeck_serial/Cargo.toml similarity index 100% rename from loupedeck_serial/Cargo.toml rename to crates/loupedeck_serial/Cargo.toml diff --git a/loupedeck_serial/src/characteristics.rs b/crates/loupedeck_serial/src/characteristics.rs similarity index 100% rename from loupedeck_serial/src/characteristics.rs rename to crates/loupedeck_serial/src/characteristics.rs diff --git a/loupedeck_serial/src/commands.rs b/crates/loupedeck_serial/src/commands.rs similarity index 100% rename from loupedeck_serial/src/commands.rs rename to crates/loupedeck_serial/src/commands.rs diff --git a/loupedeck_serial/src/device.rs b/crates/loupedeck_serial/src/device.rs similarity index 100% rename from loupedeck_serial/src/device.rs rename to crates/loupedeck_serial/src/device.rs diff --git a/loupedeck_serial/src/events.rs b/crates/loupedeck_serial/src/events.rs similarity index 100% rename from loupedeck_serial/src/events.rs rename to crates/loupedeck_serial/src/events.rs diff --git a/loupedeck_serial/src/lib.rs b/crates/loupedeck_serial/src/lib.rs similarity index 100% rename from loupedeck_serial/src/lib.rs rename to crates/loupedeck_serial/src/lib.rs diff --git a/loupedeck_serial/src/messages.rs b/crates/loupedeck_serial/src/messages.rs similarity index 100% rename from loupedeck_serial/src/messages.rs rename to crates/loupedeck_serial/src/messages.rs diff --git a/loupedeck_serial/src/util.rs b/crates/loupedeck_serial/src/util.rs similarity index 100% rename from loupedeck_serial/src/util.rs rename to crates/loupedeck_serial/src/util.rs diff --git a/pa-volume-interface/Cargo.toml b/crates/pa_volume_interface/Cargo.toml similarity index 87% rename from pa-volume-interface/Cargo.toml rename to crates/pa_volume_interface/Cargo.toml index 10b2a0b..f90db8c 100644 --- a/pa-volume-interface/Cargo.toml +++ b/crates/pa_volume_interface/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "pa-volume-interface" +name = "pa_volume_interface" version = "0.1.0" edition = "2021" diff --git a/pa-volume-interface/src/lib.rs b/crates/pa_volume_interface/src/lib.rs similarity index 100% rename from pa-volume-interface/src/lib.rs rename to crates/pa_volume_interface/src/lib.rs diff --git a/deckster/Cargo.toml b/deckster/Cargo.toml deleted file mode 100644 index 396aba0..0000000 --- a/deckster/Cargo.toml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/deckster/src/icons/mod.rs b/deckster/src/icons/mod.rs deleted file mode 100644 index dc79143..0000000 --- a/deckster/src/icons/mod.rs +++ /dev/null @@ -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 { - let mut result: HashSet = HashSet::new(); - - fn insert_all_from_key_style_by_state_map(result: &mut HashSet, map: &KeyStyleByStateMap) { - map.values().for_each(|v| { - if let Some(icon) = &v.icon { - result.insert(icon.clone()); - } - }); - } - - fn insert_all_from_knob_style_by_state_map(result: &mut HashSet, map: &KnobStyleByStateMap) { - 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, - descriptors: HashSet, - dpi: f32, -) -> Result { - let mut highest_scale_by_source: HashMap = 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 = 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 { - 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, 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, - ); -} diff --git a/deckster/src/modes/key/command.rs b/deckster/src/modes/key/command.rs deleted file mode 100644 index 2542e5c..0000000 --- a/deckster/src/modes/key/command.rs +++ /dev/null @@ -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, mut events: broadcast::Receiver) { - 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), - } - } - } -} diff --git a/deckster/src/modes/key/home_assistant.rs b/deckster/src/modes/key/home_assistant.rs deleted file mode 100644 index 9817006..0000000 --- a/deckster/src/modes/key/home_assistant.rs +++ /dev/null @@ -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, -} - -#[derive(Debug, Deserialize)] -pub struct ButtonConfig { - pub name: String, - #[serde(default)] - pub style: KeyStyleByStateMap, -} - -#[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, -} diff --git a/deckster/src/modes/key/mod.rs b/deckster/src/modes/key/mod.rs deleted file mode 100644 index 3e94aa2..0000000 --- a/deckster/src/modes/key/mod.rs +++ /dev/null @@ -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)>, - events: broadcast::Sender<(KeyPath, KeyEvent)>, - commands: flume::Sender, -) { - 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; - } - } - } - }); - } -} diff --git a/deckster/src/modes/key/playerctl.rs b/deckster/src/modes/key/playerctl.rs deleted file mode 100644 index 287843a..0000000 --- a/deckster/src/modes/key/playerctl.rs +++ /dev/null @@ -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, - 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, -} - -#[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, -} - -#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum LoopState { - Inactive, - None, - Single, - All, -} - -struct PlayerctlStateWatcher { - state: Arc>, - new_states: broadcast::Sender, -} - -impl PlayerctlStateWatcher { - 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 { - let r = self.new_states.subscribe(); - self.new_states.send(self.state.read().unwrap().clone()).unwrap(); - r - } -} - -static STATE_WATCHER_PLAYING: Lazy> = 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> = 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> = 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, mut events: broadcast::Receiver, commands: flume::Sender) { - 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, mut events: broadcast::Receiver, commands: flume::Sender) { - 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, mut events: broadcast::Receiver, commands: flume::Sender) { - 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) - } - } - } - } - } -} diff --git a/deckster/src/modes/key/timer.rs b/deckster/src/modes/key/timer.rs deleted file mode 100644 index e99dcb3..0000000 --- a/deckster/src/modes/key/timer.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::time::Duration; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct Config { - pub durations: Vec, - #[serde(default)] - pub vibrate_when_finished: bool, - #[serde(default)] - pub needy: bool, -} - -#[derive(Debug, Deserialize)] -pub struct DurationWrapper(#[serde(with = "humantime_serde")] pub Duration); diff --git a/deckster/src/modes/knob/audio_volume.rs b/deckster/src/modes/knob/audio_volume.rs deleted file mode 100644 index e076eb2..0000000 --- a/deckster/src/modes/knob/audio_volume.rs +++ /dev/null @@ -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, - #[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, -} - -#[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, -} - -#[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 = 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, mut events: broadcast::Receiver<(KnobPath, KnobEvent)>, commands: flume::Sender) { - let mut entity_state: Option> = 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>| { - 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>| { - 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) - } - } - _ => {} - } - } - } - } - } -} diff --git a/deckster/src/modes/knob/mod.rs b/deckster/src/modes/knob/mod.rs deleted file mode 100644 index 4c5c573..0000000 --- a/deckster/src/modes/knob/mod.rs +++ /dev/null @@ -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)>, - events: broadcast::Sender<(KnobPath, KnobEvent)>, - commands: flume::Sender, -) { - 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())); - } - } -} diff --git a/deckster/src/modes/mod.rs b/deckster/src/modes/mod.rs deleted file mode 100644 index f5682d4..0000000 --- a/deckster/src/modes/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod key; -pub mod knob; diff --git a/deckster_mode/src/lib.rs b/deckster_mode/src/lib.rs deleted file mode 100644 index ba50a9c..0000000 --- a/deckster_mode/src/lib.rs +++ /dev/null @@ -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, im::HashMap) -> H>( - init_handler: I, -) -> Result<(), RunError> { - let mut handler: Either = 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(()) -} diff --git a/deckster_shared/src/path.rs b/deckster_shared/src/path.rs deleted file mode 100644 index 338c355..0000000 --- a/deckster_shared/src/path.rs +++ /dev/null @@ -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 and separated by an 'x'")] -pub struct KeyPositionFromStrError {} - -impl FromStr for KeyPosition { - type Err = KeyPositionFromStrError; - - fn from_str(s: &str) -> Result { - 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, -} diff --git a/deckster/examples/full/deckster.toml b/examples/full/deckster.toml similarity index 62% rename from deckster/examples/full/deckster.toml rename to examples/full/deckster.toml index e3b33a6..0784f5b 100644 --- a/deckster/examples/full/deckster.toml +++ b/examples/full/deckster.toml @@ -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" diff --git a/examples/full/handlers/pa_volume b/examples/full/handlers/pa_volume new file mode 100755 index 0000000..682cd79 Binary files /dev/null and b/examples/full/handlers/pa_volume differ diff --git a/deckster/examples/full/icons/apps/discord.svg b/examples/full/icons/apps/discord.svg similarity index 100% rename from deckster/examples/full/icons/apps/discord.svg rename to examples/full/icons/apps/discord.svg diff --git a/deckster/examples/full/icons/apps/spotify.svg b/examples/full/icons/apps/spotify.svg similarity index 100% rename from deckster/examples/full/icons/apps/spotify.svg rename to examples/full/icons/apps/spotify.svg diff --git a/deckster/examples/full/icons/apps/youtube.svg b/examples/full/icons/apps/youtube.svg similarity index 100% rename from deckster/examples/full/icons/apps/youtube.svg rename to examples/full/icons/apps/youtube.svg diff --git a/deckster/examples/full/icons/fad/repeat-one.svg b/examples/full/icons/fad/repeat-one.svg similarity index 100% rename from deckster/examples/full/icons/fad/repeat-one.svg rename to examples/full/icons/fad/repeat-one.svg diff --git a/deckster/examples/full/icons/fad/repeat.svg b/examples/full/icons/fad/repeat.svg similarity index 100% rename from deckster/examples/full/icons/fad/repeat.svg rename to examples/full/icons/fad/repeat.svg diff --git a/deckster/examples/full/icons/fad/shuffle.svg b/examples/full/icons/fad/shuffle.svg similarity index 100% rename from deckster/examples/full/icons/fad/shuffle.svg rename to examples/full/icons/fad/shuffle.svg diff --git a/deckster/examples/full/icons/fad/thunderbolt.svg b/examples/full/icons/fad/thunderbolt.svg similarity index 100% rename from deckster/examples/full/icons/fad/thunderbolt.svg rename to examples/full/icons/fad/thunderbolt.svg diff --git a/deckster/examples/full/icons/ph/computer-tower.svg b/examples/full/icons/ph/computer-tower.svg similarity index 100% rename from deckster/examples/full/icons/ph/computer-tower.svg rename to examples/full/icons/ph/computer-tower.svg diff --git a/deckster/examples/full/icons/ph/microphone-light.svg b/examples/full/icons/ph/microphone-light.svg similarity index 100% rename from deckster/examples/full/icons/ph/microphone-light.svg rename to examples/full/icons/ph/microphone-light.svg diff --git a/deckster/examples/full/icons/ph/microphone-slash-light.svg b/examples/full/icons/ph/microphone-slash-light.svg similarity index 100% rename from deckster/examples/full/icons/ph/microphone-slash-light.svg rename to examples/full/icons/ph/microphone-slash-light.svg diff --git a/deckster/examples/full/icons/ph/pause.svg b/examples/full/icons/ph/pause.svg similarity index 100% rename from deckster/examples/full/icons/ph/pause.svg rename to examples/full/icons/ph/pause.svg diff --git a/deckster/examples/full/icons/ph/play-pause.svg b/examples/full/icons/ph/play-pause.svg similarity index 100% rename from deckster/examples/full/icons/ph/play-pause.svg rename to examples/full/icons/ph/play-pause.svg diff --git a/deckster/examples/full/icons/ph/play.svg b/examples/full/icons/ph/play.svg similarity index 100% rename from deckster/examples/full/icons/ph/play.svg rename to examples/full/icons/ph/play.svg diff --git a/deckster/examples/full/icons/ph/skip-back.svg b/examples/full/icons/ph/skip-back.svg similarity index 100% rename from deckster/examples/full/icons/ph/skip-back.svg rename to examples/full/icons/ph/skip-back.svg diff --git a/deckster/examples/full/icons/ph/skip-forward.svg b/examples/full/icons/ph/skip-forward.svg similarity index 100% rename from deckster/examples/full/icons/ph/skip-forward.svg rename to examples/full/icons/ph/skip-forward.svg diff --git a/deckster/examples/full/icons/ph/timer.svg b/examples/full/icons/ph/timer.svg similarity index 100% rename from deckster/examples/full/icons/ph/timer.svg rename to examples/full/icons/ph/timer.svg diff --git a/deckster/examples/full/key-pages/default.toml b/examples/full/key-pages/default.toml similarity index 100% rename from deckster/examples/full/key-pages/default.toml rename to examples/full/key-pages/default.toml diff --git a/deckster/examples/full/knob-pages/default.toml b/examples/full/knob-pages/default.toml similarity index 94% rename from deckster/examples/full/knob-pages/default.toml rename to examples/full/knob-pages/default.toml index 57a4ac5..0619ebf 100644 --- a/deckster/examples/full/knob-pages/default.toml +++ b/examples/full/knob-pages/default.toml @@ -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" diff --git a/handlers/pa_volume/Cargo.toml b/handlers/pa_volume/Cargo.toml new file mode 100644 index 0000000..35491cd --- /dev/null +++ b/handlers/pa_volume/Cargo.toml @@ -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" \ No newline at end of file diff --git a/handlers/pa_volume/src/handler.rs b/handlers/pa_volume/src/handler.rs new file mode 100644 index 0000000..dc6e12d --- /dev/null +++ b/handlers/pa_volume/src/handler.rs @@ -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, + #[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, +} + +#[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, +} + +#[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 = 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, +} + +impl Handler { + pub fn new(data: InitialHandlerMessage<(), KnobConfig>) -> Result { + 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); + } +} diff --git a/handlers/pa_volume/src/main.rs b/handlers/pa_volume/src/main.rs new file mode 100644 index 0000000..7c49c73 --- /dev/null +++ b/handlers/pa_volume/src/main.rs @@ -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(()) +} diff --git a/src/handler_host/mod.rs b/src/handler_host/mod.rs new file mode 100644 index 0000000..57b592c --- /dev/null +++ b/src/handler_host/mod.rs @@ -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, + pub mode_string: Box, + pub handler_config: Arc, +} + +pub async fn start( + handlers_directory: &Path, + key_configs: HashMap, + knob_configs: HashMap, + commands_sender: flume::Sender, + events_receiver: flume::Receiver, +) -> Result<()> { + let handler_names: Vec = 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, 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::() + )); + } 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::() + )); + } + }; + + 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::(&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(()) +} diff --git a/deckster/src/icons/destructive_filter.rs b/src/icons/destructive_filter.rs similarity index 100% rename from deckster/src/icons/destructive_filter.rs rename to src/icons/destructive_filter.rs diff --git a/src/icons/mod.rs b/src/icons/mod.rs new file mode 100644 index 0000000..d3d39e2 --- /dev/null +++ b/src/icons/mod.rs @@ -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>, + 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>, 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 { + 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 { + 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) +} diff --git a/deckster/src/main.rs b/src/main.rs similarity index 97% rename from deckster/src/main.rs rename to src/main.rs index 6e993fb..9613667 100644 --- a/deckster/src/main.rs +++ b/src/main.rs @@ -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, diff --git a/deckster/src/model/config.rs b/src/model/config.rs similarity index 96% rename from deckster/src/model/config.rs rename to src/model/config.rs index 843a2d4..1e88393 100644 --- a/deckster/src/model/config.rs +++ b/src/model/config.rs @@ -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, - pub icon_packs: HashMap, + pub icon_packs: Arc>, pub buttons: HashMap, // EnumMap pub initial: InitialConfig, } @@ -37,7 +38,7 @@ pub struct Config { pub label_font_family: Option, pub key_pages_by_id: HashMap, pub knob_pages_by_id: HashMap, - pub icon_packs: HashMap, + pub icon_packs: Arc>, pub buttons: EnumMap, pub initial: InitialConfig, } diff --git a/deckster/src/model/geometry.rs b/src/model/geometry.rs similarity index 100% rename from deckster/src/model/geometry.rs rename to src/model/geometry.rs diff --git a/deckster/src/model/key_page.rs b/src/model/key_page.rs similarity index 65% rename from deckster/src/model/key_page.rs rename to src/model/key_page.rs index 957056e..87ed8e1 100644 --- a/deckster/src/model/key_page.rs +++ b/src/model/key_page.rs @@ -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>, - pub home_assistant__switch: Option>, - pub home_assistant__button: Option>, - pub playerctl__button: Option>, - pub playerctl__shuffle: Option>, - pub playerctl__loop: Option>, + pub handler: String, + pub config: Arc, } diff --git a/deckster/src/model/knob_page.rs b/src/model/knob_page.rs similarity index 72% rename from deckster/src/model/knob_page.rs rename to src/model/knob_page.rs index e672a28..c68a8de 100644 --- a/deckster/src/model/knob_page.rs +++ b/src/model/knob_page.rs @@ -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, @@ -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>, + pub handler: String, + pub config: Arc, } diff --git a/deckster/src/model/mod.rs b/src/model/mod.rs similarity index 100% rename from deckster/src/model/mod.rs rename to src/model/mod.rs diff --git a/deckster/src/model/position.rs b/src/model/position.rs similarity index 100% rename from deckster/src/model/position.rs rename to src/model/position.rs diff --git a/deckster/src/runner/graphics.rs b/src/runner/graphics.rs similarity index 93% rename from deckster/src/runner/graphics.rs rename to src/runner/graphics.rs index 3f9e842..1a207fe 100644 --- a/deckster/src/runner/graphics.rs +++ b/src/runner/graphics.rs @@ -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, pub buffer_endianness: Endianness, - pub global_icon_filter_by_pack_id: HashMap, - 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 { diff --git a/deckster/src/runner/mod.rs b/src/runner/mod.rs similarity index 83% rename from deckster/src/runner/mod.rs rename to src/runner/mod.rs index 9d6c417..78ca450 100644 --- a/deckster/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -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::(5); + let (events_sender, events_receiver) = flume::bounded::(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::(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, + config: Arc, device: LoupedeckDevice, commands_sender: flume::Sender, - key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>, - knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>, + events_sender: flume::Sender, graphics: GraphicsContext, } impl IoWorkerContext { pub fn create( - config: Arc, - icons: LoadedIconsMap, + config_directory: &Path, + config: Arc, device: LoupedeckDevice, commands_sender: flume::Sender, - key_events_sender: broadcast::Sender<(KeyPath, KeyEvent)>, - knob_events_sender: broadcast::Sender<(KnobPath, KnobEvent)>, + events_sender: flume::Sender, ) -> 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 { + 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 { diff --git a/deckster/src/runner/state.rs b/src/runner/state.rs similarity index 100% rename from deckster/src/runner/state.rs rename to src/runner/state.rs