diff --git a/Cargo.lock b/Cargo.lock index e921daa..229b08f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -643,6 +643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05aaa8004b64fd573fc9d002f4e632d51ad4f026c2b5ba95fcb6c2f32c2c47d8" dependencies = [ "bytemuck", + "serde", ] [[package]] diff --git a/deckster/Cargo.toml b/deckster/Cargo.toml index f21100f..c2a4517 100644 --- a/deckster/Cargo.toml +++ b/deckster/Cargo.toml @@ -8,7 +8,7 @@ color-eyre = "0.6.2" humantime-serde = "1.1.1" loupedeck_serial = { path = "../loupedeck_serial" } piet = "0.6.2" -rgb = "0.8.37" +rgb = { version = "0.8.37", features = ["serde"] } serde = { version = "1.0.193", features = ["derive"] } serde_regex = "1.1.0" serde_with = "3.4.0" diff --git a/deckster/examples/full/knob-pages/default.toml b/deckster/examples/full/knob-pages/default.toml index b93397f..7a1bd2b 100644 --- a/deckster/examples/full/knob-pages/default.toml +++ b/deckster/examples/full/knob-pages/default.toml @@ -3,7 +3,7 @@ icon = "@ph/microphone-light" mode.audio_volume.mode = "input" mode.audio_volume.regex = "Microphone" mode.audio_volume.label.muted = "Muted" -mode.audio_volume.icon.inactive = "@ph/microphone-sllash-light[brighten=50,huerotate=red,opacity=90]" +mode.audio_volume.icon.inactive = "@ph/microphone-sllash-light[alpha=90|brighten=50|huerotate=red]" mode.audio_volume.circle_indicator.color = "#ffffff" mode.audio_volume.circle_indicator.width = 2 mode.audio_volume.circle_indicator.radius = 40 diff --git a/deckster/src/model/geometry.rs b/deckster/src/model/geometry.rs new file mode 100644 index 0000000..1f74296 --- /dev/null +++ b/deckster/src/model/geometry.rs @@ -0,0 +1,36 @@ +use std::str::FromStr; + +use piet::kurbo::{Rect, Vec2}; + +pub fn parse_positive_int_vec2_from_str(s: &str, separator: char) -> Result { + let values = s.split_once(separator); + + if let Some((x, y)) = values { + if let Some(x) = usize::from_str(x).ok() { + if let Some(y) = usize::from_str(y).ok() { + return Ok(Vec2::new(x as f64, y as f64)); + } + } + } + + Err(()) +} + +pub fn parse_positive_rect_from_str(s: &str) -> Result { + let (pairs, is_corner_points_mode) = if let Some(pairs) = s.split_once('+') { + (pairs, false) + } else if let Some(pairs) = s.split_once('-') { + (pairs, true) + } else { + return Err(()); + }; + + let first_vec = parse_positive_int_vec2_from_str(pairs.0, 'x')?; + let second_vec = parse_positive_int_vec2_from_str(pairs.1, 'x')?; + + Ok(if is_corner_points_mode { + Rect::from_points(first_vec.to_point(), second_vec.to_point()) + } else { + Rect::from_origin_size(first_vec.to_point(), second_vec.to_size()) + }) +} diff --git a/deckster/src/model/image_filter.rs b/deckster/src/model/image_filter.rs index 2e81ae7..4c14450 100644 --- a/deckster/src/model/image_filter.rs +++ b/deckster/src/model/image_filter.rs @@ -1,40 +1,22 @@ +use crate::model::geometry::parse_positive_rect_from_str; +use crate::model::rgb::parse_rgb8_from_hex_str; use piet::kurbo::Rect; +use rgb::RGB8; use serde_with::DeserializeFromStr; -use std::fmt::{Display, Formatter, Write}; use std::str::FromStr; use thiserror::Error; -#[derive(Debug, Clone)] -pub enum ImageTransform { - Invert, - Brighten(f32), - Crop(Rect), - HorizontalFlip, - VerticalFlip, - Rotate { quarter_clockwise_turns: u8 }, -} - -impl Display for ImageTransform { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ImageTransform::Invert => f.write_str("invert"), - ImageTransform::Brighten(value) => f.write_fmt(format_args!("brighten={}", value)), - ImageTransform::Crop(_) => f.write_str("crop=<...>"), - ImageTransform::HorizontalFlip => f.write_str("horizontal_flip"), - ImageTransform::VerticalFlip => f.write_str("vertical_flip"), - ImageTransform::Rotate { quarter_clockwise_turns } => f.write_fmt(format_args!("rotate={}", quarter_clockwise_turns)), - }?; - - Ok(()) - } -} - #[derive(DeserializeFromStr)] pub struct ImageFilter { + crop_original: Option, // applied before scale and rotate scale: f32, + clockwise_quarter_rotations: u8, + crop: Option, // applied after scale and rotate + color: Option, alpha: f32, blur: f32, - transforms: Vec, + grayscale: bool, + invert: bool, } #[derive(Debug, Error)] @@ -43,17 +25,17 @@ pub enum ImageFilterFromStringError { UnknownFilter { name: String }, #[error("Filter {name} can only be used once.")] - SingletonSpecifiedMoreThanOnce { name: String }, + FilterUsedMoreThanOnce { name: String }, - #[error("All singleton filters must be placed before any transforms, but {singleton} was placed after {transform}.")] - SingletonSpecifiedAfterTransform { singleton: String, transform: ImageTransform }, - - #[error("{quarter_clockwise_turns} 90° turns can be simplified.")] - PointlessRotation { quarter_clockwise_turns: u8 }, + #[error("Only the following values are for rotate: -90, 90, 180, 270")] + RotationNotAllowed, #[error("Filter {filter_name} requires a value ({filter_name}=).")] FilterValueMissing { filter_name: String }, + #[error("Filter {filter_name} does not require or allow a value.")] + FilterValueIgnored { filter_name: String }, + #[error("The following value supplied to {filter_name} could not be parsed: {raw_value}")] FilterValueNotParsable { filter_name: String, raw_value: String }, @@ -64,14 +46,51 @@ pub enum ImageFilterFromStringError { impl FromStr for ImageFilter { type Err = ImageFilterFromStringError; - fn from_str(s: &str) -> Result { + fn from_str<'a>(s: &str) -> Result { + fn parse_rect_filter_value(filter_name: &str, raw_value: String) -> Result { + parse_positive_rect_from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { + filter_name: filter_name.to_string(), + raw_value, + }) + } + + fn parse_f32_filter_value( + filter_name: &str, + raw_value: String, + range_string: &str, + check_range: Box bool>, + ) -> Result { + let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { + filter_name: filter_name.to_string(), + raw_value, + })?; + + if !check_range(&value) { + return Err(ImageFilterFromStringError::FilterValueNotInRange { + filter_name: filter_name.to_string(), + range: range_string.to_string(), + value: value.to_string(), + }); + } + + Ok(value) + } + let filters: Vec<&str> = s.split('|').map(|f| f.trim()).collect(); - let scale: Option = None; - let alpha: Option = None; - let blur: Option = None; + let mut result = ImageFilter { + crop_original: None, + scale: 1.0, + clockwise_quarter_rotations: 0, + crop: None, + color: None, + alpha: 1.0, + blur: 0.0, + grayscale: false, + invert: false, + }; - let mut transforms: Vec = Vec::new(); + let mut previous_filter_names: Vec = Vec::new(); for filter in filters { let split_filter = filter.split_once('='); @@ -81,6 +100,23 @@ impl FromStr for ImageFilter { (filter.to_owned(), None) }; + if previous_filter_names.contains(&filter_name) { + return Err(ImageFilterFromStringError::FilterUsedMoreThanOnce { name: filter_name }); + } else { + previous_filter_names.push(filter_name.clone()); + } + + let value_is_present = optional_raw_value.is_some(); + let use_bool_value = || -> Result { + if value_is_present { + Err(ImageFilterFromStringError::FilterValueIgnored { + filter_name: filter_name.clone(), + }) + } else { + Ok(true) + } + }; + let use_raw_value = || -> Result { optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing { filter_name: filter_name.clone(), @@ -88,51 +124,42 @@ impl FromStr for ImageFilter { }; match filter_name.as_str() { - "scale" | "alpha" | "blur" => { - if let Some(transform) = transforms.first() { - return Err(ImageFilterFromStringError::SingletonSpecifiedAfterTransform { - singleton: filter_name.clone(), - transform: transform.clone(), - }); - } - - #[allow(clippy::type_complexity)] - let (mut variable, check_range, range_string): (Option, Box bool>, &'static str) = match filter_name.as_str() { - "scale" => (scale, Box::new(|v| (0.0..=10.0_f32).contains(v)), "0..=10"), - "alpha" => (alpha, Box::new(|v| (0.0..1.0_f32).contains(v)), "0..<1"), - "blur" => (blur, Box::new(|v| (0.0..=10.0_f32).contains(v)), "0..=10"), - _ => unreachable!("filter_name is narrowed down in the outer match statement"), - }; - - if variable.is_some() { - return Err(ImageFilterFromStringError::SingletonSpecifiedMoreThanOnce { name: filter_name.clone() }); - } - + "crop_original" => result.crop_original = Some(parse_rect_filter_value(&filter_name, use_raw_value()?)?), + "scale" => result.scale = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=100", Box::new(|v| (0.0..=100.0).contains(v)))?, + "rotate" => { let raw_value = use_raw_value()?; - let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { - filter_name: filter_name.clone(), + let value = i32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { + filter_name: filter_name.to_string(), raw_value, })?; - if !check_range(&value) { - return Err(ImageFilterFromStringError::FilterValueNotInRange { - filter_name: filter_name.clone(), - range: range_string.to_owned(), - value: value.to_string(), - }); - } - - let _ = variable.insert(value); + result.clockwise_quarter_rotations = match value { + 0 => 0, + 90 => 1, + 180 => 2, + 270 | -90 => 3, + _ => return Err(ImageFilterFromStringError::RotationNotAllowed), + }; } + "crop" => result.crop = Some(parse_rect_filter_value(&filter_name, use_raw_value()?)?), + "color" => { + let raw_value = use_raw_value()?; + + result.color = Some( + parse_rgb8_from_hex_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { + filter_name: filter_name.to_string(), + raw_value, + })?, + ) + } + "alpha" => result.alpha = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..<1", Box::new(|v| (0.0..1.0).contains(v)))?, + "blur" => result.blur = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=10", Box::new(|v| (0.0..=10.0).contains(v)))?, + "grayscale" => result.grayscale = use_bool_value()?, + "invert" => result.invert = use_bool_value()?, _ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }), - } + }; } - Ok(ImageFilter { - scale: scale.unwrap_or(1.0), - alpha: alpha.unwrap_or(1.0), - blur: blur.unwrap_or(0.0), - transforms, - }) + Ok(result) } } diff --git a/deckster/src/model/mod.rs b/deckster/src/model/mod.rs index 32c0c98..35ee79e 100644 --- a/deckster/src/model/mod.rs +++ b/deckster/src/model/mod.rs @@ -1,2 +1,4 @@ mod deckster_file; -mod image_filter; \ No newline at end of file +mod image_filter; +mod geometry; +mod rgb; \ No newline at end of file diff --git a/deckster/src/model/rgb.rs b/deckster/src/model/rgb.rs new file mode 100644 index 0000000..f62a464 --- /dev/null +++ b/deckster/src/model/rgb.rs @@ -0,0 +1,37 @@ +use rgb::{RGB8, RGBA8}; + +pub fn parse_rgb8_from_hex_str(s: &str) -> Result { + let first_index = if s.starts_with('#') { 1 } else { 0 }; + if s.len() - first_index == 6 { + let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ())?; + let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ())?; + let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ())?; + + return Ok(RGB8::new(r, g, b)); + } + + Err(()) +} + +pub fn parse_rgba8_from_hex_str(s: &str) -> Result { + let first_index = if s.starts_with('#') { 1 } else { 0 }; + if s.len() - first_index == 8 { + let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ())?; + let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ())?; + let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ())?; + let a = u8::from_str_radix(&s[(first_index + 6)..(first_index + 8)], 16).map_err(|_| ())?; + + return Ok(RGBA8::new(r, g, b, a)); + } + + Err(()) +} + +pub fn parse_rgb8_with_optional_alpha_from_hex_str(s: &str, fallback_alpha: u8) -> Result { + // optionally +1 for the '#' + match s.len() { + 6 | 7 => Ok(parse_rgb8_from_hex_str(s)?.alpha(fallback_alpha)), + 8 | 9 => Ok(parse_rgba8_from_hex_str(s)?), + _ => Err(()), + } +}