diff --git a/deckster/examples/full/deckster.toml b/deckster/examples/full/deckster.toml index 906ce3c..abe8277 100644 --- a/deckster/examples/full/deckster.toml +++ b/deckster/examples/full/deckster.toml @@ -28,9 +28,9 @@ format = "svg" [icon_packs.fad] path = "icons/fad" format = "svg" -global_filter = "invert" +global_filter = "invert|scale=1.2" [icon_packs.ph] path = "icons/ph" format = "svg" -global_filter = "invert" \ No newline at end of file +global_filter = "invert|scale=1.2" \ No newline at end of file diff --git a/deckster/examples/full/key-pages/default.toml b/deckster/examples/full/key-pages/default.toml index 0dd1268..dc690b7 100644 --- a/deckster/examples/full/key-pages/default.toml +++ b/deckster/examples/full/key-pages/default.toml @@ -1,31 +1,31 @@ [keys.1x1] -icon = "@ph/play[invert|alpha=0.5|scale=2.0]" +icon = "@apps/spotify[scale=2.0|grayscale]" mode.vibrate.pattern = "low" mode.media__play_pause.icon.paused = "@ph/play" mode.media__play_pause.icon.playing = "@ph/pause" [keys.1x2] -icon = "@fad/shuffle[invert]" +icon = "@fad/shuffle[alpha=0.6]" mode.vibrate.pattern = "low" mode.spotify__shuffle.icon.active = "@fad/shuffle[color=#58fc11]" [keys.2x1] -icon = "@ph/timer[invert|scale=0.5]" +icon = "@ph/timer[color=#ff0000]" mode.vibrate.pattern = "low" mode.timer.durations = ["60s", "5m", "10m", "15m", "30m"] mode.timer.vibrate_when_finished = true mode.timer.needy = true [keys.3x3] -icon = "@fad/thunderbolt[invert]" +icon = "@fad/thunderbolt[border=#00ff00]" label = "Dock" mode.vibrate.pattern = "low" mode.home_assistant__switch.name = "switch.moritz_thunderbolt_dock" mode.home_assistant__switch.icon.on = "@fad/thunderbolt[color=#58fc11]" -[keys.3x4] -icon = "@ph/computer-tower[invert]" -label = "Tower PC" +[keys.4x3] +icon = "@ph/computer-tower[border=#00ff00]" +label = "Tower PC unnötig lang" mode.vibrate.pattern = "low" mode.home_assistant__switch.name = "switch.mwin" mode.home_assistant__switch.icon.on = "@ph/computer-tower[color=#58fc11]" \ No newline at end of file diff --git a/deckster/src/icons/filter.rs b/deckster/src/icons/filter.rs index 15a3f79..331bd09 100644 --- a/deckster/src/icons/filter.rs +++ b/deckster/src/icons/filter.rs @@ -2,7 +2,6 @@ use color_eyre::{eyre::ContextCompat, Result}; use tiny_skia::{ColorU8, Pixmap, PremultipliedColorU8}; use crate::model::image_filter::ImageFilter; -use crate::model::rgb::RGB8Wrapper; pub fn apply(original: &Pixmap, filter: &ImageFilter) -> Result { let mut result = if let Some(rect) = filter.crop { @@ -11,45 +10,34 @@ pub fn apply(original: &Pixmap, filter: &ImageFilter) -> Result { original.clone() }; - // scale is handled in runner::graphics::render_key + // scale, rotate and border are handled in runner::graphics::render_key - // rotate + for p in result.pixels_mut() { + if filter.invert { + *p = PremultipliedColorU8::from_rgba(p.alpha() - p.red(), p.alpha() - p.green(), p.alpha() - p.blue(), p.alpha()).unwrap(); + } - if let Some(color) = &filter.color { - apply_color(&mut result, color); - } + if filter.alpha != 1.0 { + let d = p.demultiply(); + *p = ColorU8::from_rgba(d.red(), d.green(), d.blue(), (d.alpha() as f32 * filter.alpha).round() as u8).premultiply(); + } - if filter.alpha != 1.0 { - apply_alpha(&mut result, filter.alpha); - } + if filter.grayscale { + let a = p.alpha(); + if a > 0 { + // The values are adjusted to make it look right on the Loupedeck Live display + let luminosity = (0.3 * p.red() as f32 + 0.6 * p.green() as f32 + 0.1 * p.blue() as f32).floor() as u8; + *p = ColorU8::from_rgba(luminosity, luminosity, luminosity, a).premultiply(); + } + } - // grayscale - - if filter.invert { - apply_invert(&mut result); + if let Some(color) = &filter.color { + let a = p.alpha(); + if a > 0 { + *p = ColorU8::from_rgba(color.r, color.g, color.b, a).premultiply(); + } + } } Ok(result) } - -fn apply_alpha(pixmap: &mut Pixmap, alpha: f32) { - for p in pixmap.pixels_mut() { - let d = p.demultiply(); - *p = ColorU8::from_rgba(d.red(), d.green(), d.blue(), (d.alpha() as f32 * alpha).round() as u8).premultiply(); - } -} - -fn apply_invert(pixmap: &mut Pixmap) { - for p in pixmap.pixels_mut() { - *p = PremultipliedColorU8::from_rgba(p.alpha() - p.red(), p.alpha() - p.green(), p.alpha() - p.blue(), p.alpha()).unwrap(); - } -} - -fn apply_color(pixmap: &mut Pixmap, color: &RGB8Wrapper) { - for p in pixmap.pixels_mut() { - let a = p.alpha(); - if a > 0 { - *p = ColorU8::from_rgba(color.r, color.g, color.b, a).premultiply(); - } - } -} diff --git a/deckster/src/icons/mod.rs b/deckster/src/icons/mod.rs index d8e2266..66d0d40 100644 --- a/deckster/src/icons/mod.rs +++ b/deckster/src/icons/mod.rs @@ -9,6 +9,7 @@ use tiny_skia::{Pixmap, Transform}; use crate::model::config::{Config, IconFormat, IconPack}; use crate::model::icon_descriptor::{IconDescriptor, IconDescriptorSource}; +use crate::model::rgb::RGB8Wrapper; use crate::model::IconMap; mod filter; @@ -17,6 +18,8 @@ mod filter; pub struct LoadedIcon { pub pixmap: Pixmap, pub scale: f32, + pub clockwise_quarter_rotations: u8, + pub border: Option, } pub fn get_used_icon_descriptors(config: &Config) -> HashSet { @@ -83,7 +86,27 @@ pub fn load_icons( descriptors: HashSet, dpi: f32, ) -> Result> { - let mut unfiltered_pixmap_by_source: HashMap = HashMap::new(); + let mut highest_scale_by_source: HashMap = HashMap::new(); + + for d in &descriptors { + let mut scale = d.filter.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.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_scaled_pixmap_by_source: HashMap = HashMap::new(); let mut icons_by_descriptor: HashMap = HashMap::new(); let mut fonts_db = resvg::usvg::fontdb::Database::new(); fonts_db.load_system_fonts(); @@ -93,29 +116,59 @@ pub fn load_icons( continue; } - let original_image = match unfiltered_pixmap_by_source.entry(descriptor.source.clone()) { + let (original_image, original_image_scale) = match unfiltered_scaled_pixmap_by_source.entry(descriptor.source.clone()) { Entry::Occupied(o) => o.into_mut(), - Entry::Vacant(v) => v.insert(read_image(config_directory, icon_packs_by_id, dpi, &fonts_db, descriptor.source.clone())?), + Entry::Vacant(v) => v.insert(read_image_and_get_scale( + config_directory, + icon_packs_by_id, + dpi, + &fonts_db, + descriptor.source.clone(), + highest_scale_by_source[&descriptor.source], + )?), }; - let pixmap = filter::apply(original_image, &descriptor.filter)?; - let scale = descriptor.filter.scale; + let (pixmap, scale) = if let IconDescriptorSource::IconPack { pack_id, .. } = &descriptor.source { + let pack = &icon_packs_by_id[pack_id]; + if let Some(global_filter) = &pack.global_filter { + ( + filter::apply(&filter::apply(original_image, global_filter)?, &descriptor.filter)?, + descriptor.filter.scale * global_filter.scale, + ) + } else { + (filter::apply(original_image, &descriptor.filter)?, descriptor.filter.scale) + } + } else { + (filter::apply(original_image, &descriptor.filter)?, descriptor.filter.scale) + }; - icons_by_descriptor.insert(descriptor, LoadedIcon { pixmap, scale }); + let scale = scale / *original_image_scale; + let clockwise_quarter_rotations = descriptor.filter.clockwise_quarter_rotations; + let border = descriptor.filter.border; + icons_by_descriptor.insert( + descriptor, + LoadedIcon { + pixmap, + scale, + clockwise_quarter_rotations, + border, + }, + ); } Ok(icons_by_descriptor) } -fn read_image( +fn read_image_and_get_scale( config_directory: &Path, icon_packs_by_id: &HashMap, dpi: f32, fonts_db: &resvg::usvg::fontdb::Database, source: IconDescriptorSource, -) -> Result { + highest_scale: f32, +) -> Result<(Pixmap, f32)> { let path = match source { - IconDescriptorSource::None => return Ok(Pixmap::new(1, 1).unwrap()), + IconDescriptorSource::None => return Ok((Pixmap::new(1, 1).unwrap(), 1.0)), IconDescriptorSource::Path(path) => path, IconDescriptorSource::IconPack { pack_id, icon_id } => { let pack = icon_packs_by_id.get(&pack_id).wrap_err_with(|| format!("Unknown icon pack: @{}", pack_id))?; @@ -132,16 +185,21 @@ fn read_image( 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()))?, - "svg" => { - read_image_from_svg(&path, dpi, fonts_db).wrap_err_with(|| format!("Failed to open or decode the SVG file at {}", path.to_string_lossy()))? - } + "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) -> Result { +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 = { @@ -162,9 +220,9 @@ fn read_image_from_svg(path: &Path, dpi: f32, font_db: &resvg::usvg::fontdb::Dat }; let size = tree.size.to_int_size(); - let mut pixmap = Pixmap::new(size.width(), size.height()).unwrap(); + 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::default(), &mut pixmap.as_mut()); + tree.render(Transform::from_scale(scale, scale), &mut pixmap.as_mut()); Ok(pixmap) } diff --git a/deckster/src/model/image_filter.rs b/deckster/src/model/image_filter.rs index 85e77e6..6c976c8 100644 --- a/deckster/src/model/image_filter.rs +++ b/deckster/src/model/image_filter.rs @@ -17,6 +17,7 @@ pub struct ImageFilter { pub alpha: f32, pub grayscale: bool, pub invert: bool, + pub border: Option, } impl Eq for ImageFilter { @@ -37,6 +38,7 @@ const DEFAULT_IMAGE_FILTER: ImageFilter = ImageFilter { alpha: 1.0, grayscale: false, invert: false, + border: None, }; impl Default for ImageFilter { @@ -53,7 +55,7 @@ pub enum ImageFilterFromStringError { #[error("Filter {name} can only be used once.")] FilterUsedMoreThanOnce { name: String }, - #[error("Only the following values are for rotate: -90, 90, 180, 270")] + #[error("Only the following values are allowed for rotate: -90, 90, 180, 270")] RotationNotAllowed, #[error("Filter {filter_name} requires a value ({filter_name}=).")] @@ -169,6 +171,16 @@ impl FromStr for ImageFilter { "alpha" => result.alpha = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..<1", Box::new(|v| (0.0..1.0).contains(v)))?, "grayscale" => result.grayscale = use_bool_value()?, "invert" => result.invert = use_bool_value()?, + "border" => { + let raw_value = use_raw_value()?; + + result.border = Some( + RGB8Wrapper::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable { + filter_name: filter_name.to_string(), + raw_value, + })?, + ) + } _ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }), }; } @@ -202,7 +214,7 @@ impl Display for ImageFilter { f.write_str("|")? } - f.write_fmt(format_args!("rotate={}", self.clockwise_quarter_rotations * 90))?; + f.write_fmt(format_args!("rotate={}", self.clockwise_quarter_rotations as u16 * 90))?; is_first = false; } @@ -236,6 +248,15 @@ impl Display for ImageFilter { f.write_str("|")? } f.write_str("invert")?; + is_first = false; + } + + if let Some(color) = self.color { + if !is_first { + f.write_str("|")? + } + + f.write_fmt(format_args!("color={}", color))?; // is_first = false; } diff --git a/deckster/src/runner/graphics.rs b/deckster/src/runner/graphics.rs index 0131af4..26fd6e2 100644 --- a/deckster/src/runner/graphics.rs +++ b/deckster/src/runner/graphics.rs @@ -1,16 +1,26 @@ use std::collections::HashMap; use bytes::{BufMut, Bytes, BytesMut}; -use tiny_skia::{BlendMode, Color, FilterQuality, IntSize, Pixmap, PixmapPaint, PremultipliedColorU8, Transform}; +use resvg::usvg::tiny_skia_path::PathBuilder; +use tiny_skia::{ + BlendMode, Color, FilterQuality, IntSize, LineCap, LineJoin, Paint, Pixmap, PixmapPaint, PremultipliedColorU8, Rect, Shader, Stroke, Transform, +}; use loupedeck_serial::util::Endianness; use crate::icons::LoadedIcon; use crate::model::icon_descriptor::{IconDescriptor, IconDescriptorSource}; +use crate::runner::graphics::labels::LabelRenderer; use crate::runner::state::Key; -pub fn render_key(key_size: IntSize, buffer_endianness: Endianness, icons: &HashMap, state: Option<&Key>) -> Bytes { - let mut canvas = Pixmap::new(key_size.width(), key_size.height()).unwrap(); +pub fn render_key( + label_renderer: &mut LabelRenderer, + key_size: IntSize, + buffer_endianness: Endianness, + icons: &HashMap, + state: Option<&Key>, +) -> Bytes { + let mut pixmap = Pixmap::new(key_size.width(), key_size.height()).unwrap(); if let Some(state) = state { if state.icon.source != IconDescriptorSource::None { @@ -26,20 +36,47 @@ pub fn render_key(key_size: IntSize, buffer_endianness: Endianness, icons: &Hash quality: FilterQuality::Bicubic, }; - canvas.draw_pixmap( - (((key_size.width() - scaled_size.width()) / 2) as f32 / icon.scale).round() as i32, - (((key_size.height() - scaled_size.height()) / 2) as f32 / icon.scale).round() as i32, + pixmap.draw_pixmap( + (((key_size.width() as i32 - scaled_size.width() as i32) / 2) as f32 / icon.scale).round() as i32, + (((key_size.height() as i32 - scaled_size.height() as i32) / 2) as f32 / icon.scale).round() as i32, icon.pixmap.as_ref(), &PAINT, - Transform::from_scale(icon.scale, icon.scale), + Transform::from_scale(icon.scale, icon.scale).post_rotate_at( + (icon.clockwise_quarter_rotations as f32) * 90.0, + key_size.width() as f32 / 2.0, + key_size.height() as f32 / 2.0, + ), None, ); + + if let Some(color) = icon.border { + let path = PathBuilder::from_rect(Rect::from_xywh(-1.0, -2.0, pixmap.width() as f32, pixmap.height() as f32).unwrap()); + + let paint = Paint { + shader: Shader::SolidColor(Color::from_rgba8(color.r, color.g, color.b, 255)), + ..Paint::default() + }; + + static STROKE: Stroke = Stroke { + width: 15.0, + miter_limit: 4.0, + line_cap: LineCap::Butt, + line_join: LineJoin::Round, + dash: None, + }; + + pixmap.stroke_path(&path, &paint, &STROKE, Transform::identity(), None); + } + } + + if !state.label.is_empty() { + label_renderer.render(&mut pixmap, &state.label); } } else { - canvas.fill(Color::BLACK); + pixmap.fill(Color::BLACK); } - convert_pixels_to_rgb565(canvas.pixels(), buffer_endianness).freeze() + convert_pixels_to_rgb565(pixmap.pixels(), buffer_endianness).freeze() } fn convert_pixels_to_rgb565(pixels: &[PremultipliedColorU8], endianness: Endianness) -> BytesMut { @@ -61,3 +98,62 @@ fn convert_pixels_to_rgb565(pixels: &[PremultipliedColorU8], endianness: Endiann result } + +pub mod labels { + use cosmic_text::{Align, Attrs, AttrsList, Buffer, BufferLine, FontSystem, Metrics, Shaping, SwashCache}; + use tiny_skia::{Color, Paint, Pixmap, Rect, Shader, Transform}; + + pub struct LabelRenderer { + font_system: FontSystem, + swash_cache: SwashCache, + buffer: Buffer, + } + + impl LabelRenderer { + pub fn new() -> Self { + let mut font_system = FontSystem::new(); + let buffer = Buffer::new(&mut font_system, Metrics::new(11.0, 11.0)); + + LabelRenderer { + font_system, + swash_cache: SwashCache::new(), + buffer, + } + } + + pub fn render(&mut self, pixmap: &mut Pixmap, text: &String) { + let attrs = Attrs::new(); + let mut line = BufferLine::new(text, AttrsList::new(attrs), Shaping::Advanced); + line.set_align(Some(Align::Center)); + + self.buffer.lines.clear(); + self.buffer.lines.push(line); + + const PADDING: f32 = 12.0; + self.buffer.set_size( + &mut self.font_system, + pixmap.width() as f32 - PADDING * 2.0, + pixmap.height() as f32 - PADDING * 2.0, + ); + + self.buffer.shape_until_scroll(&mut self.font_system); + + self.buffer.draw( + &mut self.font_system, + &mut self.swash_cache, + cosmic_text::Color::rgb(255, 255, 255), + |x, y, w, h, color| { + pixmap.fill_rect( + Rect::from_xywh(x as f32 + PADDING, y as f32 + PADDING, w as f32, h as f32).unwrap(), + &Paint { + shader: Shader::SolidColor(Color::from_rgba8(color.r(), color.g(), color.b(), color.a())), + ..Paint::default() + }, + Transform::identity(), + None, + ); + }, + ); + } + } +} diff --git a/deckster/src/runner/mod.rs b/deckster/src/runner/mod.rs index ee21ff6..cee91e6 100644 --- a/deckster/src/runner/mod.rs +++ b/deckster/src/runner/mod.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -22,6 +23,7 @@ use crate::icons::{get_used_icon_descriptors, load_icons, LoadedIcon}; use crate::model; use crate::model::icon_descriptor::IconDescriptor; use crate::model::{ButtonPosition, KeyPath, KeyPosition, KnobPath}; +use crate::runner::graphics::labels::LabelRenderer; use crate::runner::graphics::render_key; use crate::runner::state::{Key, State, StateChangeCommand}; @@ -136,6 +138,7 @@ enum IoWork { struct IoWorkerContext { config: Arc, icons: HashMap, + label_renderer: RefCell, device: LoupedeckDevice, state: State, } @@ -149,7 +152,13 @@ fn do_io_work( commands_receiver: Receiver, ) { let state = create_state(&config); - let mut context = IoWorkerContext { config, icons, device, state }; + let mut context = IoWorkerContext { + config, + icons, + label_renderer: RefCell::new(LabelRenderer::new()), + device, + state, + }; loop { let a = flume::Selector::new() @@ -306,7 +315,13 @@ fn draw_key(context: &IoWorkerContext, index: u8, key: Option<&Key>) { let key_grid = &context.device.characteristics().key_grid; let (x, y, w, h) = key_grid.get_local_key_rect_xywh(index).unwrap(); - let p = render_key(IntSize::from_wh(w as u32, h as u32).unwrap(), key_grid.display.endianness, &context.icons, key); + let p = render_key( + &mut context.label_renderer.borrow_mut(), + IntSize::from_wh(w as u32, h as u32).unwrap(), + key_grid.display.endianness, + &context.icons, + key, + ); context.device.replace_framebuffer_area_raw(&key_grid.display, x, y, w, h, p).unwrap(); }