diff --git a/Cargo.toml b/Cargo.toml index b18e3fa..fdf3996 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ rusttype = "0.9.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } lazy_static = "1.4.0" +font-loader = "0.11.0" diff --git a/src/config.rs b/src/config.rs index d87d37a..4be9554 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,30 +1,40 @@ -use serde::Deserialize; use dirs::config_dir; +use serde::Deserialize; +use serde_json; use std::{ + collections::HashMap, env, fmt::{self, Display}, fs, + hash::Hash, io::ErrorKind, path::PathBuf, - collections::HashMap, - sync::Arc + sync::Arc, }; use tracing::debug; -use serde_json; /// The name of the folder which holds the config pub const CONFIG_FOLDER_NAME: &'static str = "microdeck"; pub const CONFIG_FILE: &'static str = "config.json"; +/// Combination of buttons acting as a folder which a device can switch to +pub type Space = Vec>; + +/// CONFIGURATION #[derive(Deserialize, Debug)] pub struct Config { pub global: Option, pub devices: Vec, + pub spaces: Arc>, } +/// settings that effect all devices #[derive(Deserialize, Debug)] -pub struct GlobalConfig; +pub struct GlobalConfig { + pub font_family: Option, +} +/// configuration of a single device with its default page #[derive(Deserialize, Debug, Clone)] pub struct DeviceConfig { pub serial: String, @@ -42,13 +52,18 @@ pub struct Button { pub index: u8, pub module: String, /// options which get passed to the module - pub options: Option>, - /// allows to overwrite what it will do on a click (executes in /bin/sh) + #[serde(default = "new_hashmap")] + pub options: HashMap, + /// allows to overwrite what it will do on a click pub on_click: Option, /// allows to overwrite what it will do on a release pub on_release: Option, } +fn new_hashmap() -> HashMap { + HashMap::new() +} + #[tracing::instrument] pub fn load_config() -> Result { let config_file: PathBuf = match env::var_os("DACH_DECKER_CONFIG") { diff --git a/src/device.rs b/src/device.rs index 6064fcc..2d29e11 100644 --- a/src/device.rs +++ b/src/device.rs @@ -1,5 +1,5 @@ use crate::{ - config::{Button, ConfigError, DeviceConfig}, + config::{Button, ConfigError, DeviceConfig, Space}, modules::{retrieve_module_from_name, start_module, HostEvent}, unwrap_or_error, }; @@ -41,6 +41,7 @@ pub struct Device { device: Arc, modules_runtime: Option, config: DeviceConfig, + spaces: Arc>, serial: String, } @@ -49,6 +50,7 @@ impl Device { serial: String, kind: Kind, device_conf: DeviceConfig, + spaces: Arc>, hid: &HidApi, ) -> Result { // connect to deck or continue to next @@ -78,6 +80,7 @@ impl Device { device: deck, modules_runtime: None, config: device_conf, + spaces, serial, }) } @@ -108,16 +111,16 @@ impl Device { let dev = self.device.clone(); let b = btn.clone(); - runtime.spawn(async move { - start_module(ser, b, module, dev, module_receiver).await - }); + runtime + .spawn(async move { start_module(ser, b, module, dev, module_receiver).await }); } // if the receiver already dropped the listener then just directly insert none. // Optimizes performance because the key_listener just does not try to send the event. if module_sender.is_closed() { self.modules.insert(btn.index, (btn.clone(), None)); } else { - self.modules.insert(btn.index, (btn.clone(), Some(module_sender))); + self.modules + .insert(btn.index, (btn.clone(), Some(module_sender))); } return Ok(()); } else { @@ -137,11 +140,7 @@ impl Device { if let Some(handle) = self.modules_runtime.take() { handle.shutdown_background(); } - } - - /// if there is no runtime left or the runtime never got initialized - pub fn is_dropped(&self) -> bool { - self.modules_runtime.is_none() + self.modules = HashMap::new(); } /// if this device holds any modules @@ -172,22 +171,36 @@ impl Device { } } + /// Switch to a space. This will tear down the whole runtime of the current space. + #[tracing::instrument(skip_all, fields(serial = self.serial))] + async fn switch_to_space(&mut self, name: &String) { + debug!("Switching to space {}", name); + self.drop(); + if let Some(space) = self.spaces.get(name) { + self.config.buttons = space.clone(); + self.device.reset().await.unwrap(); + self.init_modules().await; + } else { + error!("Space {} was not found", name); + }; + } + /// Handle all incoming button state updates from the listener (shell actions, module sender) async fn button_state_update(&mut self, event: ButtonStateUpdate) { // get the index out of the enum... let index = match event { ButtonStateUpdate::ButtonUp(i) => i, - ButtonStateUpdate::ButtonDown(i) => i + ButtonStateUpdate::ButtonDown(i) => i, }; // try to get config for the module let options = match self.modules.get_mut(&index) { Some(options) => options, - None => return + None => return, }; // action will only be some if on_click/on_release is specified in config let (action, event) = match event { ButtonStateUpdate::ButtonDown(_) => (&options.0.on_click, HostEvent::ButtonPressed), - ButtonStateUpdate::ButtonUp(_) => (&options.0.on_release, HostEvent::ButtonReleased) + ButtonStateUpdate::ButtonUp(_) => (&options.0.on_release, HostEvent::ButtonReleased), }; // try to send to module and drop the sender if the receiver was droppped if let Some(sender) = options.to_owned().1 { @@ -200,10 +213,17 @@ impl Device { if let Some(action) = action { execute_sh(&action).await } + // switch space if needed + if options.0.module == "space" { + let name = match options.0.options.get("NAME") { + Some(n) => n.clone(), + None => return, + }; + self.switch_to_space(&name).await; + } } } - pub async fn execute_sh(command: &str) { match Command::new("sh").arg(command).output().await { Ok(o) => debug!("Command \'{}\' returned: {}", command, o.status), diff --git a/src/main.rs b/src/main.rs index 0e46896..5d8a578 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,28 @@ +use crate::config::{Config, DeviceConfig, GlobalConfig}; use deck_driver as streamdeck; -use crate::config::{Config, DeviceConfig}; use device::Device; +use font_loader::system_fonts::{FontProperty, FontPropertyBuilder}; use hidapi::HidApi; -use std::{collections::HashMap, process::exit, time::Duration}; -use tracing::{debug, error, info, warn}; +use rusttype::Font; +use std::{ + collections::HashMap, + process::exit, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; +use tracing::{debug, error, info, trace, warn}; use tracing_subscriber::{ self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, EnvFilter, }; -use config::load_config; +use config::{load_config, Space}; mod config; mod device; mod modules; +pub static GLOBAL_FONT: OnceLock = OnceLock::new(); + #[macro_export] macro_rules! skip_if_none { ($res:expr) => { @@ -53,6 +62,29 @@ fn main() { } }; + // load font + // TODO: make this prettier + let font = match config.global { + Some(ref g) => { + if let Some(family) = &g.font_family { + font_loader::system_fonts::get( + &mut FontPropertyBuilder::new().family(family.as_str()).build(), + ) + .unwrap_or_else(|| { + warn!("Unable to load custom font"); + load_system_font() + }) + } else { + load_system_font() + } + } + None => load_system_font(), + }; + + GLOBAL_FONT + .set(Font::try_from_vec(font.0).expect("Unable to parse font. Maybe try another font?")) + .unwrap(); + debug!("{:#?}", config); let hid = streamdeck::new_hidapi().expect("Could not create HidApi"); @@ -64,6 +96,12 @@ fn main() { .block_on(start(config, hid)) } +fn load_system_font() -> (Vec, i32) { + debug!("Retrieving system font"); + font_loader::system_fonts::get(&mut FontPropertyBuilder::new().monospace().build()) + .expect("Unable to load system monospace font. Please specify a custom font in the config.") +} + pub async fn start(config: Config, mut hid: HidApi) { let mut devices: HashMap = HashMap::new(); @@ -72,7 +110,7 @@ pub async fn start(config: Config, mut hid: HidApi) { loop { // check for devices that can be removed - let mut removable_devices = Vec::new(); + /* let mut removable_devices = Vec::new(); for (key, device) in &devices { if device.is_dropped() { removable_devices.push(key.to_owned()); @@ -80,7 +118,7 @@ pub async fn start(config: Config, mut hid: HidApi) { } for d in removable_devices { devices.remove(&d); - } + }*/ // refresh device list if let Err(e) = streamdeck::refresh_device_list(&mut hid) { @@ -90,12 +128,20 @@ pub async fn start(config: Config, mut hid: HidApi) { // if the device is not ignored and device is not already started if !ignore_devices.contains(&hw_device.1) && devices.get(&hw_device.1).is_none() { debug!("New device detected: {}", &hw_device.1); - if let Some(device_config) = - config.devices.iter().find(|d| d.serial == hw_device.1) + // match regex for device serial + if let Some(device_config) = config + .devices + .iter() + .find(|d| d.serial == hw_device.1 || d.serial == "*") { // start the device and its listener - if let Some(device) = - start_device(hw_device, &hid, device_config.clone()).await + if let Some(device) = start_device( + hw_device, + &hid, + device_config.clone(), + config.spaces.clone(), + ) + .await { devices.insert(device.serial(), device); } @@ -117,16 +163,11 @@ pub async fn start_device( device: (streamdeck::info::Kind, String), hid: &HidApi, device_config: DeviceConfig, + spaces: Arc>, ) -> Option { - match Device::new(device.1, device.0, device_config, &hid).await { + match Device::new(device.1, device.0, device_config, spaces, &hid).await { Ok(mut device) => { info!("Connected"); - // start all modules or print out the error - /* device_config.buttons.iter().for_each(|button| { - device - .create_module(&button) - .unwrap_or_else(|e| error!("{}", e)) - });*/ device.init_modules().await; device.key_listener().await; if !device.has_modules() { @@ -140,4 +181,3 @@ pub async fn start_device( } } } - diff --git a/src/modules.rs b/src/modules.rs index 6bccf9c..fca6ffa 100644 --- a/src/modules.rs +++ b/src/modules.rs @@ -1,15 +1,24 @@ mod blank; mod counter; +mod space; +// modules use self::counter::Counter; +use self::space::Space; + +use crate::GLOBAL_FONT; +// other things use crate::config::Button; use async_trait::async_trait; pub use deck_driver as streamdeck; use futures_util::Future; -use image::DynamicImage; +use image::{DynamicImage, Rgb, RgbImage}; +use imageproc::drawing::draw_text_mut; use lazy_static::lazy_static; +use rusttype::Scale; use std::collections::HashMap; use std::pin::Pin; +use std::str::FromStr; use std::{error::Error, sync::Arc}; pub use streamdeck::info::ImageFormat; use streamdeck::info::Kind; @@ -22,6 +31,7 @@ lazy_static! { static ref MODULE_MAP: HashMap<&'static str, ModuleFunction> = { let mut m = HashMap::new(); m.insert("counter", Counter::run as ModuleFunction); + m.insert("space", Space::run as ModuleFunction); m }; } @@ -103,9 +113,52 @@ impl DeviceAccess { self.format().size } - pub fn kind(&self) -> Kind { - self.kind + /// Draw an image with text + /// + /// 60% image + /// 40% text + pub fn image_with_text(&self, image: Vec, text: String, config: &Button) -> DynamicImage { + let res = self.resolution(); + let mut image = RgbImage::new(res.0 as u32, res.1 as u32); + draw_text_mut( + &mut image, + Rgb([255, 255, 255]), + -10, + 10, + Scale::uniform(parse_config(config, &"FONT_SIZE".into(), 20.0)), + &GLOBAL_FONT.get().unwrap(), + &text, + ); + image::DynamicImage::ImageRgb8(image) } + + /// Draw text + pub fn text(&self, text: String, config: &Button) -> DynamicImage { + let res = self.resolution(); + let mut image = RgbImage::new(res.0 as u32, res.1 as u32); + draw_text_mut( + &mut image, + Rgb([255, 255, 255]), + 10, + 10, + Scale::uniform(parse_config(config, &"FONT_SIZE".into(), 20.0)), + &GLOBAL_FONT.get().unwrap(), + &text, + ); + image::DynamicImage::ImageRgb8(image) + } +} + +/// reads a key from the config and parses the config in the given type +fn parse_config(config: &Button, key: &String, if_wrong_type: T) -> T +where + T: FromStr, +{ + let out = match config.options.get(key) { + Some(value) => value.parse::().unwrap_or(if_wrong_type), + None => if_wrong_type, + }; + out } pub type ReturnError = Box; diff --git a/src/modules/counter.rs b/src/modules/counter.rs index acefe73..6857227 100644 --- a/src/modules/counter.rs +++ b/src/modules/counter.rs @@ -6,9 +6,6 @@ use super::Module; use super::{ChannelReceiver, DeviceAccess, HostEvent, ReturnError}; use async_trait::async_trait; -use image::{Rgb, RgbImage}; -use imageproc::drawing::draw_text_mut; -use rusttype::{Font, Scale}; /// A module which displays a counter pub struct Counter; @@ -18,35 +15,25 @@ impl Module for Counter { async fn run( streamdeck: DeviceAccess, button_receiver: ChannelReceiver, - _config: Arc