diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 00a067a..994080f 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -1,28 +1,67 @@
-# This workflow will build a .NET project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
+# Build and validate StreamDeck-Tools across all supported TFMs and platforms.
+# netstandard2.0 builds on all platforms with any .NET SDK.
+# net48 is Windows-only (requires .NET Framework targeting pack).
+# net8.0 and net10.0 build on Windows, macOS, and Linux.
name: .NET
on:
push:
- branches: [ "master" ]
+ branches: [ "master", "feature/**" ]
pull_request:
branches: [ "master" ]
jobs:
- build:
-
+ build-windows:
+ name: Windows (netstandard2.0 + net48 + net8.0 + net10.0)
runs-on: windows-latest
-
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Setup .NET
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
- dotnet-version: 8.0.x
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
- run: dotnet build --no-restore
+ run: dotnet build --no-restore --configuration Release
- name: Test
- run: dotnet test --no-build --verbosity normal
+ run: dotnet test --no-build --configuration Release --verbosity normal
+
+ build-macos:
+ name: macOS (netstandard2.0 + net8.0 + net10.0)
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+ - name: Build netstandard2.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0
+ - name: Build net8.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0
+ - name: Build net10.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net10.0
+
+ build-linux:
+ name: Linux (netstandard2.0 + net8.0 + net10.0)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+ - name: Build netstandard2.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework netstandard2.0
+ - name: Build net8.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net8.0
+ - name: Build net10.0
+ run: dotnet build barraider-sdtools/barraider-sdtools.csproj --configuration Release --framework net10.0
diff --git a/.gitignore b/.gitignore
index 4d13c54..219ddf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -332,3 +332,6 @@ ASALocalRun/
# Local History for Visual Studio
.localhistory/
+
+# Internal migration planning docs (not shipped with the library)
+docs/
diff --git a/MigrateTo7.0.md b/MigrateTo7.0.md
index 537009f..bf7d2eb 100644
--- a/MigrateTo7.0.md
+++ b/MigrateTo7.0.md
@@ -1,36 +1,9 @@
# Migrating to StreamDeck-Tools v7.0
-This guide helps you upgrade your Stream Deck plugin from StreamDeck-Tools v6.x to v7.0.
+This guide helps you upgrade your Stream Deck plugin from StreamDeck-Tools v6.x to v7.0, including the new cross-platform SkiaSharp API surface.
> **AI-assisted migration:** This document is designed to work well with AI coding assistants (Cursor, Copilot, etc.). Point your AI at this file and ask it to migrate your plugin -- the tables and recipes below give it everything it needs.
-## What's New in v7.0
-
-- **Cross-platform support** -- plugins can now run on both Windows and macOS
-- **SkiaSharp graphics** -- new cross-platform drawing APIs alongside the existing System.Drawing ones
-- **.NET 10 support** -- library targets `netstandard2.0`, `net48`, `net8.0`, and `net10.0`
-- **Self-contained deployment** -- ship your plugin with the runtime included, no user install required
-
-## Do I Need to Change My Code?
-
-Maybe not! Here's a quick check:
-
-```
-Does your plugin use System.Drawing directly in its own code?
- │
- ├── NO → Just upgrade the NuGet package. Build. Done.
- │ You'll see [Obsolete] warnings -- these are informational.
- │ Everything still works on Windows.
- │
- └── YES → Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel?
- │
- ├── NO → Straightforward rename using the tables below.
- │
- └── YES → Rewrite those sections using SkiaSharp directly.
-```
-
-Most plugins (roughly half) fall into the "just upgrade" category.
-
## Step 1: Upgrade the NuGet Package
```
@@ -40,7 +13,7 @@ Update-Package StreamDeck-Tools
Or set the version manually in your `.csproj`:
```xml
-
+
```
Build your project. Everything should compile. You'll see `[Obsolete]` warnings on System.Drawing methods -- these point you to the SkiaSharp replacements.
@@ -55,32 +28,91 @@ using SkiaSharp;
The `BarRaider.SdTools` namespace already contains `SkiaTools`, `SkiaGraphicsTools`, and `SkiaExtensionMethods`.
-## Step 3: Replace System.Drawing Calls
+---
+
+## What Changed in v7.0
-### Class Mapping
+### Target Frameworks
-| Legacy Class | Replacement |
+| Before (v6.x) | After (v7.0) |
|---|---|
-| `Tools` (image/font methods) | `SkiaTools` |
+| `netstandard2.0` + `net48` | `netstandard2.0` + `net48` + `net8.0` + `net10.0` |
+
+The library retains `netstandard2.0` for broad compatibility and `net48` for .NET Framework consumers. `net8.0` (LTS) and `net10.0` (current) are added for modern .NET. `net9.0` is not included (EOL May 2026).
+
+### New Dependency: SkiaSharp 3.119.2
+
+The library now ships with [SkiaSharp](https://github.com/mono/SkiaSharp) (MIT license) as a cross-platform graphics backend. SkiaSharp works on Windows, macOS, and Linux without GDI+.
+
+### Dual API Surface
+
+Every public method that accepted or returned `System.Drawing` types now has a SkiaSharp equivalent in a parallel class:
+
+| Legacy Class | SkiaSharp Replacement |
+|---|---|
+| `Tools` | `SkiaTools` |
| `GraphicsTools` | `SkiaGraphicsTools` |
| `ExtensionMethods` (on `Image`/`Graphics`) | `SkiaExtensionMethods` (on `SKBitmap`/`SKCanvas`) |
-### Type Mapping
+### System.Drawing APIs Marked `[Obsolete]`
+
+All public methods using `System.Drawing` types are now marked `[Obsolete]` with messages pointing to their SkiaSharp replacements. They still compile and work on Windows but will **not** work on macOS/Linux with .NET 8+.
+
+### New `SetImageAsync` Overloads
-| System.Drawing | SkiaSharp | Notes |
+`ISDConnection` and `SDConnection` now support:
+
+| Method | Status | Platform |
+|---|---|---|
+| `SetImageAsync(SKBitmap, ...)` | **New** | Cross-platform |
+| `SetImageAsync(byte[], ...)` | **New** | Cross-platform (raw PNG bytes) |
+| `SetImageAsync(string, ...)` | Unchanged | Cross-platform (base64) |
+| `SetImageAsync(Image, ...)` | `[Obsolete]` | Windows-only |
+
+### TitleParameters Changes
+
+`TitleParameters` retains its existing constructors and properties unchanged. New read-only SkiaSharp properties are added:
+
+| Property / Method | Type | Description |
+|---|---|---|
+| `FontFamilyName` | `string` | Cross-platform font family name. Replaces `FontFamily.Name`. |
+| `TitleSKColor` | `SKColor` | Derived from `TitleColor`. |
+| `TitleTypeface` | `SKTypeface` | Derived from `FontFamilyName` + `FontStyle`. Cached. |
+| `FontStyleToSKFontStyle()` | `SKFontStyle` | Converts `System.Drawing.FontStyle` to `SKFontStyle`. |
+
+> **Breaking change (macOS):** `TitleParameters.FontFamily` (the `System.Drawing.FontFamily` property) is `[Obsolete]` and **throws `PlatformNotSupportedException`** on non-Windows. This is a hard crash, not just a warning. Any code that accesses `.FontFamily` (including in `OnTitleParametersDidChange` handlers) must switch to `.FontFamilyName` or `.TitleTypeface`.
+
+### DrawTextLine
+
+New extension method `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` draws text treating Y as the **top of the text** (matching `System.Drawing.Graphics.DrawString` behavior) and returns the Y position for the next line.
+
+Use `DrawTextLine` instead of raw `SKCanvas.DrawText` when stacking multiple lines -- `DrawText` uses baseline Y which leads to positioning bugs.
+
+`DrawAndMeasureString` is also available on `SKCanvas` and delegates to `DrawTextLine`.
+
+### PluginBase Deprecation
+
+`PluginBase` is `[Obsolete]`. Use `KeypadBase` (keys only), `EncoderBase` (dials only), or `KeyAndEncoderBase` (both).
+
+---
+
+## Type Mapping Reference
+
+| System.Drawing Type | SkiaSharp Type | Notes |
|---|---|---|
-| `Image` / `Bitmap` | `SKBitmap` | Disposable |
-| `Graphics` | `SKCanvas` | Created from `SKBitmap` |
-| `Color` | `SKColor` | Struct, no disposal |
-| `Font` | `SKFont` | Disposable |
-| `FontFamily` | `SKTypeface` | `SKTypeface.FromFamilyName(...)` |
-| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `.Normal`, etc. |
-| `SolidBrush` | `SKPaint` | `Style = SKPaintStyle.Fill` |
-| `Pen` | `SKPaint` | `Style = SKPaintStyle.Stroke` |
+| `Image` / `Bitmap` | `SKBitmap` | `IDisposable`. Use `using` blocks. |
+| `Graphics` | `SKCanvas` | Created from `SKBitmap`. `IDisposable`. |
+| `Color` | `SKColor` | Struct, no disposal needed. |
+| `Font` | `SKFont` | `IDisposable`. Holds typeface + size. |
+| `FontFamily` | `SKTypeface` | Use `SKTypeface.FromFamilyName(...)`. |
+| `FontStyle` | `SKFontStyle` | `SKFontStyle.Bold`, `SKFontStyle.Normal`, etc. |
+| `SolidBrush` | `SKPaint` | Set `Style = SKPaintStyle.Fill` and `Color`. |
+| `Pen` | `SKPaint` | Set `Style = SKPaintStyle.Stroke` and `StrokeWidth`. |
| `PointF` | `SKPoint` | |
| `Rectangle` / `RectangleF` | `SKRect` / `SKRectI` | |
-| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | |
+| `ColorTranslator.FromHtml(...)` | `SkiaTools.ColorFromHex(...)` | Or `SKColor.TryParse(...)` directly. |
| `Image.FromFile(...)` | `SkiaTools.LoadImage(path)` | |
+| `Image.FromStream(...)` | `SkiaTools.LoadImage(stream)` | |
### Common Patterns
@@ -90,36 +122,101 @@ The `BarRaider.SdTools` namespace already contains `SkiaTools`, `SkiaGraphicsToo
| `ColorTranslator.FromHtml("#FF0000")` | `SkiaTools.ColorFromHex("#FF0000")` |
| `new Font("Arial", 12)` | `SkiaTools.CreateFont("Arial", 12)` |
| `Image.FromFile("icon.png")` | `SkiaTools.LoadImage("icon.png")` |
-| `graphics.DrawString(text, font, brush, point)` | `canvas.DrawText(text, x, y, font, paint)` |
+| `graphics.DrawString(text, font, brush, point)` | `canvas.DrawTextLine(text, font, paint, point)` |
| `graphics.FillRectangle(brush, rect)` | `canvas.DrawRect(rect, paint)` |
-### Method Migration
+---
-| Legacy | Replacement |
-|---|---|
-| `Tools.GenerateKeyImage(type, out Graphics)` | `SkiaTools.GenerateKeyImage(type, out SKCanvas)` |
-| `Tools.GenerateGenericKeyImage(out Graphics)` | `SkiaTools.GenerateGenericKeyImage(out SKCanvas)` |
-| `Tools.ImageToBase64(Image, bool)` | `SkiaTools.ImageToBase64(SKBitmap, bool)` |
-| `Tools.Base64StringToImage(string)` → `Image` | `SkiaTools.Base64StringToImage(string)` → `SKBitmap` |
-| `Tools.FileToBase64(string, bool)` | `SkiaTools.FileToBase64(string, bool)` |
-| `Tools.LoadImage(string/Stream)` → `Image` | `SkiaTools.LoadImage(string/Stream)` → `SKBitmap` |
-| `Tools.CreateFont(...)` → `Font` | `SkiaTools.CreateFont(name, size, SKFontStyle?)` → `SKFont` |
-| `GraphicsTools.ResizeImage(Image, w, h)` | `SkiaGraphicsTools.ResizeImage(SKBitmap, w, h)` |
-| `GraphicsTools.CreateOpacityImage(Image, f)` | `SkiaGraphicsTools.CreateOpacityImage(SKBitmap, f)` |
-| `Connection.SetImageAsync(Image)` | `Connection.SetImageAsync(SKBitmap)` |
-| `graphics.AddTextPath(tp, h, w, text)` | `canvas.AddTextPath(tp, h, w, text)` |
-| `graphics.GetTextCenter(text, w, font)` | `canvas.GetTextCenter(text, w, skFont)` |
-| `text.SplitToFitKey(tp, ...)` | `text.SplitToFitKey(tp, skFont, ...)` |
+## Method-by-Method Migration
-> **Coordinate difference:** `Graphics.DrawString` positions text from the top-left corner. `SKCanvas.DrawText` uses the text **baseline**. You may need to adjust Y coordinates when migrating (add the font ascent to the Y value).
+### Tools -> SkiaTools
-> **SKFont from TitleParameters:** Some methods now require an `SKFont`. Create one from `TitleParameters` like this: `new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage)`. Remember to dispose it.
+| Legacy (`Tools.*`) | Replacement (`SkiaTools.*`) | Notes |
+|---|---|---|
+| `GenerateKeyImage(DeviceType, out Graphics)` | `GenerateKeyImage(DeviceType, out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` |
+| `GenerateGenericKeyImage(out Graphics)` | `GenerateGenericKeyImage(out SKCanvas)` | Returns `SKBitmap` + `SKCanvas` |
+| `ImageToBase64(Image, bool)` | `ImageToBase64(SKBitmap, bool)` | |
+| `Base64StringToImage(string)` returns `Image` | `Base64StringToImage(string)` returns `SKBitmap` | |
+| `FileToBase64(string, bool)` | `FileToBase64(string, bool)` | Identical signature |
+| `LoadImage(string)` returns `Image` | `LoadImage(string)` returns `SKBitmap` | |
+| `LoadImage(Stream)` returns `Image` | `LoadImage(Stream)` returns `SKBitmap` | |
+| `ImageToSHA512(Image)` | `ImageToSHA512(SKBitmap)` | |
+| `CreateFont(...)` returns `Font` | `CreateFont(string, float, SKFontStyle?)` returns `SKFont` | Style defaults to Normal |
+| *(no equivalent)* | `ColorFromHex(string)` returns `SKColor` | New helper |
+
+### GraphicsTools -> SkiaGraphicsTools
+
+| Legacy (`GraphicsTools.*`) | Replacement (`SkiaGraphicsTools.*`) | Notes |
+|---|---|---|
+| `ColorFromHex(string)` returns `Color` | `ColorFromHex(string)` returns `SKColor` | |
+| `GenerateColorShades(string, int, int)` returns `Color` | `GenerateColorShades(string, int, int)` returns `SKColor` | |
+| `ResizeImage(Image, int, int)` | `ResizeImage(SKBitmap, int, int)` | Returns `SKBitmap` |
+| `ExtractRectangle(Image, ...)` | `ExtractRectangle(SKBitmap, ...)` | Returns `SKBitmap` |
+| `CreateOpacityImage(Image, float)` | `CreateOpacityImage(SKBitmap, float)` | Returns `SKBitmap` |
+| `DrawMultiLinedText(...)` with `Font`, `Color`, `PointF` | `DrawMultiLinedText(...)` with `SKFont`, `SKColor`, `SKPoint` | Returns `SKBitmap[]` |
+| `WrapStringToFitImage(string, TitleParameters, leftPad, rightPad, imageWidth)` | `WrapStringToFitImage(string, SKFont, imageWidth, leftPad, rightPad)` | Takes `SKFont` instead of `TitleParameters`. **Parameter order changed.** |
-## Code Recipes
+### ExtensionMethods -> SkiaExtensionMethods
-### Key Image Rendering
+| Legacy | Replacement | Notes |
+|---|---|---|
+| `Image.ToPngByteArray()` | `SKBitmap.ToPngByteArray()` | |
+| `Image.ToBase64(bool)` | `SKBitmap.ToBase64(bool)` | |
+| `Image.ToByteArray()` (BMP) | *(removed)* | Use `ToPngByteArray()` instead |
+| `Graphics.DrawAndMeasureString(string, Font, Brush, PointF)` | `SKCanvas.DrawTextLine(string, SKFont, SKPaint, SKPoint)` | **Preferred.** Y = top of text. Returns next-line Y. |
+| `Graphics.GetTextCenter(...)` | `SKCanvas.GetTextCenter(...)` | Same semantics |
+| `Graphics.GetFontSizeWhereTextFitsImage(...)` | `SKCanvas.GetFontSizeWhereTextFitsImage(...)` | Same semantics |
+| `Graphics.AddTextPath(TitleParameters, ...)` | `SKCanvas.AddTextPath(TitleParameters, ...)` | Uses `TitleSKColor` and `TitleTypeface` internally |
+| `string.SplitToFitKey(TitleParameters, ...)` | `string.SplitToFitKey(TitleParameters, SKFont, ...)` | **Requires explicit `SKFont` parameter** |
+| `Color.ToHex()` | Use `SKColor` directly | |
+| `Brush.ToHex()` | Use `SKPaint.Color` directly | |
+
+### Connection
+
+| Legacy | Replacement | Notes |
+|---|---|---|
+| `SetImageAsync(Image, ...)` | `SetImageAsync(SKBitmap, ...)` | Direct replacement |
+| | `SetImageAsync(byte[], ...)` | Alternative: pass raw PNG bytes |
+
+---
+
+## Getting an `SKFont` from `TitleParameters`
+
+Several methods (`WrapStringToFitImage`, `SplitToFitKey`, `GetTextCenter`, etc.) now require an `SKFont`. Create one from `TitleParameters`:
+
+```csharp
+using var font = new SKFont(titleParameters.TitleTypeface, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage);
+```
+
+The `SKFont` is `IDisposable` -- use a `using` statement or dispose it manually.
+
+---
+
+## Disposal Patterns
+
+`SKBitmap`, `SKCanvas`, `SKFont`, and `SKPaint` are all `IDisposable`. Wrap them in `using` statements.
+
+`SetImageAsync(SKBitmap)` encodes the bitmap to PNG bytes synchronously before the async send, so it is safe to dispose the bitmap immediately after the `await`:
-**Before:**
+```csharp
+using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas))
+{
+ // ... draw on canvas ...
+ canvas.Dispose();
+ await Connection.SetImageAsync(image);
+}
+// image is disposed here -- safe, encoding already completed
+```
+
+For bitmaps stored as fields (e.g., cached key images), dispose them in your action's `Dispose()` method.
+
+---
+
+## Migration Recipes
+
+### Recipe 1: Basic Key Image Rendering
+
+**Before (System.Drawing):**
```csharp
using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics))
{
@@ -130,22 +227,20 @@ using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics))
}
```
-**After:**
+**After (SkiaSharp):**
```csharp
using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas))
{
canvas.Clear(SKColors.White);
- using (var font = SkiaTools.CreateFont("Arial", 20))
- using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true })
- {
- canvas.DrawText("Hello", 10, 50, font, paint);
- }
+ using var font = SkiaTools.CreateFont("Arial", 20);
+ using var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true };
+ canvas.DrawTextLine("Hello", font, paint, new SKPoint(10, 50));
canvas.Dispose();
await Connection.SetImageAsync(image);
}
```
-### Title Text with TitleParameters
+### Recipe 2: Title Text with TitleParameters
**Before:**
```csharp
@@ -169,13 +264,27 @@ using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas))
}
```
-### Color Parsing
+> `AddTextPath` on `SKCanvas` uses `TitleSKColor` and `TitleTypeface` internally -- no changes needed beyond switching from `Graphics` to `SKCanvas`.
-**Before:** `Color c = ColorTranslator.FromHtml("#FF0000");`
+### Recipe 3: Color Parsing
-**After:** `SKColor c = SkiaTools.ColorFromHex("#FF0000");`
+**Before:**
+```csharp
+Color c = ColorTranslator.FromHtml("#FF0000");
+// or
+Color c = GraphicsTools.ColorFromHex("#FF0000");
+```
-### Image Loading
+**After:**
+```csharp
+SKColor c = SkiaTools.ColorFromHex("#FF0000");
+// or
+SKColor c = SkiaGraphicsTools.ColorFromHex("#FF0000");
+// or
+SKColor.TryParse("#FF0000", out SKColor c);
+```
+
+### Recipe 4: Image Loading and Base64
**Before:**
```csharp
@@ -186,82 +295,178 @@ await Connection.SetImageAsync(base64);
**After:**
```csharp
-using (SKBitmap img = SkiaTools.LoadImage("icon.png"))
-{
- string base64 = SkiaTools.ImageToBase64(img, true);
- await Connection.SetImageAsync(base64);
-}
+using SKBitmap img = SkiaTools.LoadImage("icon.png");
+string base64 = SkiaTools.ImageToBase64(img, true);
+await Connection.SetImageAsync(base64);
```
-### Font Creation
+Or skip the base64 step entirely:
+```csharp
+using SKBitmap img = SkiaTools.LoadImage("icon.png");
+await Connection.SetImageAsync(img);
+```
-**Before:** `Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel);`
+### Recipe 5: Image Resizing
+
+**Before:**
+```csharp
+Image resized = GraphicsTools.ResizeImage(original, 72, 72);
+```
**After:**
```csharp
-using (SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold))
-{
- // use font for drawing
-}
+SKBitmap resized = SkiaGraphicsTools.ResizeImage(original, 72, 72);
```
-## Going Cross-Platform (Optional)
+### Recipe 6: Opacity
-If you want your plugin to run on both Windows and macOS:
+**Before:**
+```csharp
+Image faded = GraphicsTools.CreateOpacityImage(original, 0.5f);
+```
-### 1. Target .NET 10 with Self-Contained Deployment
+**After:**
+```csharp
+SKBitmap faded = SkiaGraphicsTools.CreateOpacityImage(original, 0.5f);
+```
-```xml
-
- net10.0
- true
- true
- partial
- win-x64;osx-x64
-
+### Recipe 7: Font Creation
+
+**Before:**
+```csharp
+Font font = new Font("Arial", 14, FontStyle.Bold, GraphicsUnit.Pixel);
```
-`osx-x64` is recommended for macOS -- it runs natively on Intel and via Rosetta 2 on Apple Silicon, covering all Mac users with a single binary.
+**After:**
+```csharp
+using SKFont font = SkiaTools.CreateFont("Arial", 14, SKFontStyle.Bold);
+```
-### 2. Update manifest.json
+### Recipe 8: Text Centering
-```json
-{
- "CodePath": "com.your.plugin",
- "CodePathWin": "win-x64/com.your.plugin.exe",
- "CodePathMac": "osx-x64/com.your.plugin",
- "OS": [
- { "Platform": "windows", "MinimumVersion": "10" },
- { "Platform": "mac", "MinimumVersion": "13" }
- ]
-}
+**Before:**
+```csharp
+float x = graphics.GetTextCenter("Hello", imageWidth, font, out bool fits);
+```
+
+**After:**
+```csharp
+float x = canvas.GetTextCenter("Hello", imageWidth, skFont, out bool fits);
+```
+
+### Recipe 9: Multi-Line Text Layout with DrawTextLine
+
+Use `DrawTextLine` with layout constants tuned for the default 144x144 key image:
+
+```csharp
+const int MARGIN_LEFT = 8;
+const int MARGIN_TOP = 3;
+const int CONTENT_TOP_OFFSET = 10;
+
+using SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas);
+float y = CONTENT_TOP_OFFSET;
+
+using var font = SkiaTools.CreateFont("Arial", 22, SKFontStyle.Bold);
+using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true };
+
+y = canvas.DrawTextLine("Title", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP;
+y = canvas.DrawTextLine("Line 2", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP;
+y = canvas.DrawTextLine("Line 3", font, paint, new SKPoint(MARGIN_LEFT, y)) + MARGIN_TOP;
+
+canvas.Dispose();
+await Connection.SetImageAsync(img);
```
-### 3. Publish for Both Platforms
+Each `DrawTextLine` call returns the Y position for the next line based on `font.Spacing`. Adding `MARGIN_TOP` provides a small gap between lines.
+
+---
+
+## Migration Tiers
+
+Based on analysis of real-world plugins:
+
+### Tier 1: No Direct System.Drawing Usage (~52% of plugins)
+
+Plugins that only use library helpers (`GenerateKeyImage`, `SetImageAsync`, `SplitToFitKey`, etc.) without importing `System.Drawing` in their own code.
+
+**What to do:**
+1. Upgrade `StreamDeck-Tools` NuGet to v7.0
+2. Build -- you will see `[Obsolete]` warnings. Everything still works.
+3. When ready, follow the recipes above to switch to SkiaSharp APIs and eliminate warnings.
+
+### Tier 2: Common System.Drawing Patterns (~22% of plugins)
+
+Plugins that use `SolidBrush`, `ColorTranslator.FromHtml`, `Font`, `Image.FromFile`, or `Graphics.DrawString` in their own code. Migration is mechanical renaming using the mapping tables above.
+
+**What to do:**
+1. Upgrade the NuGet package
+2. Add `using SkiaSharp;` to files with warnings
+3. Replace System.Drawing types with SkiaSharp equivalents using the type mapping and recipes
+
+### Tier 3: Deep GDI+ Usage (~26% of plugins)
+
+Plugins that use GDI+ APIs without library wrappers:
+
+| GDI+ API | SkiaSharp Equivalent |
+|---|---|
+| `GraphicsPath`, `AddString`, `StringFormat` | `SKPath`, `SKCanvas.DrawTextOnPath` |
+| `LockBits`, `BitmapData`, `SetPixel` | `SKBitmap.GetPixels()`, `SKBitmap.SetPixel()` |
+| `RotateTransform`, matrix operations | `SKCanvas.RotateDegrees()`, `SKCanvas.SetMatrix()` |
+| `Graphics.FromImage` rendering pipelines | `new SKCanvas(bitmap)` |
+
+**What to do:**
+1. Upgrade the NuGet package
+2. Add a direct `SkiaSharp` NuGet reference to your plugin project
+3. Rewrite GDI+ drawing logic using SkiaSharp native APIs
+
+### Quick Triage Flowchart
-```powershell
-dotnet publish -c Release -r win-x64
-dotnet publish -c Release -r osx-x64
+```
+Does your plugin import System.Drawing in its own code?
+ |
+ +-- NO --> Tier 1: Upgrade the NuGet package. Done.
+ |
+ +-- YES --> Does it use GraphicsPath, LockBits, RotateTransform, or SetPixel?
+ |
+ +-- NO --> Tier 2: Mechanical rename using the mapping tables.
+ |
+ +-- YES --> Tier 3: Rewrite GDI+ logic with SkiaSharp.
```
-Assemble both outputs into your `.sdPlugin` folder with `win-x64/` and `osx-x64/` subdirectories, alongside shared assets (manifest, images, Property Inspector).
+---
## Deprecation Timeline
| Release | What Happens |
|---|---|
-| **v7.0** (current) | System.Drawing APIs marked `[Obsolete]` with guidance. Everything still works on Windows. SkiaSharp APIs available in parallel. |
-| **v7.x** | Stability period. No removals. |
-| **v8.0** (future) | System.Drawing APIs evaluated for removal based on adoption. |
+| **v7.0** (current) | All System.Drawing APIs marked `[Obsolete]` with replacement guidance. Everything still compiles and works on Windows. SkiaSharp APIs available in parallel. |
+| **v7.x** | Stability and feedback period. No removals. Additional helpers may be added based on community feedback. |
+| **v8.0** (future) | System.Drawing APIs evaluated for removal. Removal only after sufficient migration window. |
+
+---
## Compatibility Notes
-- System.Drawing APIs continue to work on **Windows** across all TFMs.
-- For **macOS/Linux**, you must use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException` on non-Windows.
-- `TitleParameters` is unchanged. The new SkiaSharp properties (`TitleSKColor`, `TitleTypeface`) are additive.
-- SkiaSharp 3.x is MIT-licensed. No licensing impact.
+- All System.Drawing APIs continue to work on **Windows** across all four TFMs.
+- For **macOS/Linux** with `net8.0`/`net10.0`, you **must** use the SkiaSharp APIs. System.Drawing throws `PlatformNotSupportedException`.
+- `TitleParameters` constructor and properties are **unchanged**. The SkiaSharp properties are purely additive.
+- Exact pixel parity between System.Drawing and SkiaSharp text rendering is not guaranteed. Functional parity is the target.
+- SkiaSharp 3.119.2 is MIT-licensed. No licensing impact on plugin developers.
+
+---
+
+## Required `using` Directives
-## Complete Example: Cross-Platform Plugin Action
+When migrating to SkiaSharp APIs, add these to your files:
+
+```csharp
+using SkiaSharp;
+using BarRaider.SdTools; // SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods
+```
+
+---
+
+## Complete Sample: Cross-Platform Plugin Action
```csharp
using BarRaider.SdTools;
@@ -279,14 +484,10 @@ public class MyAction : KeypadBase
using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas))
{
canvas.Clear(SKColors.DarkBlue);
-
- using (var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold))
- using (var paint = new SKPaint { Color = SKColors.White, IsAntialias = true })
- {
- float x = canvas.GetTextCenter("Pressed!", image.Width, font);
- canvas.DrawText("Pressed!", x, image.Height / 2f, font, paint);
- }
-
+ using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold);
+ using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true };
+ float x = canvas.GetTextCenter("Pressed!", image.Width, font);
+ canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10));
canvas.Dispose();
await Connection.SetImageAsync(image);
}
diff --git a/NUGET.md b/NUGET.md
index 0ecaa5b..7638bb1 100644
--- a/NUGET.md
+++ b/NUGET.md
@@ -4,83 +4,58 @@
[](https://www.nuget.org/packages/streamdeck-tools)
-**Author's website and contact information:** [https://barraider.com](https://barraider.com)
-** Samples of plugins using this framework: [Samples][1]
+**Author's website and contact information:** [https://barraider.com](https://barraider.com)
-### Version 7.0 (Beta)
-- **Cross-platform support** -- plugins can now run on Windows and macOS
-- **SkiaSharp graphics** -- new cross-platform drawing APIs (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`)
-- **.NET 10 support** -- targets `netstandard2.0`, `net48`, `net8.0`, `net10.0`
-- All System.Drawing APIs marked `[Obsolete]` with pointers to SkiaSharp replacements (still works on Windows)
+## Quick Start
-**Upgrading from v6.x?** See the [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MigrateTo7.0.md) for step-by-step instructions and code recipes.
+1. Derive from `KeypadBase` (keys), `EncoderBase` (dials), or `KeyAndEncoderBase` (both)
+2. Decorate with `[PluginActionId("your.action.uuid")]`
+3. Call `SDWrapper.Run(args)` in `Main()`
-## Features
-- Sample plugin now included in this project on Github
-- Simplified working with filenames from the Stream Deck SDK. See ***"Working with files"*** section below
-- Built-in integration with NLog. Use `Logger.LogMessage()` for logging.
-- Just call the `SDWrapper.Run()` and the library will take care of all the overhead
-- Just have your plugin inherit PluginBase and implement the basic functionality. Use the PluginActionId to specify the UUID from the manifest file. (see samples on github page)
-- Simplified receiving Global Settings updates through the new `ReceivedGlobalSettings` method
-- Simplified receiving updates from the Property Inspector through the new `ReceivedSettings` method along with the new `Tools.AutoPopulateSettings()` method. See the ***"Auto-populating plugin settings"*** section below.
-- Introduced a new attribute called PluginActionId to indicate the Action's UUID (See below)
-- Added support to switching plugin profiles.
-- The DeviceId that the plugin is running on is now accessible from the `Connection` object
-- Added new MD5 functions in the `Tools` helper class
-- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function.
-- ExtensionMethods for Brush/Color/Graphics objects
-- Helper functions in the `Tools` and `GraphicTools` classes
+```csharp
+[PluginActionId("com.example.myplugin")]
+public class MyAction : KeypadBase
+{
+ public MyAction(ISDConnection connection, InitialPayload payload) : base(connection, payload) { }
+
+ public async override void KeyPressed(KeyPayload payload)
+ {
+ using SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas);
+ canvas.Clear(SKColors.DarkBlue);
+ using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold);
+ using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true };
+ canvas.DrawTextLine("Hello!", font, paint, new SKPoint(30, 60));
+ canvas.Dispose();
+ await Connection.SetImageAsync(image);
+ }
+
+ public override void KeyReleased(KeyPayload payload) { }
+ public override void OnTick() { }
+ public override void Dispose() { }
+ public override void ReceivedSettings(ReceivedSettingsPayload payload) { }
+ public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { }
+}
+```
-## How do I use this?
-A list of plugins already using this library can be found [here][1]
+## Features
-This library wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic.
-After creating a C# Console application, using this library requires two steps:
+- Encapsulates all Stream Deck communication -- just implement your action logic
+- Cross-platform SkiaSharp-based graphics API (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`)
+- Target frameworks: netstandard2.0, net48, net8.0, net10.0
+- Built-in NLog integration via `Logger.LogMessage()`
+- Auto-populate settings from the Property Inspector with `Tools.AutoPopulateSettings()`
+- Global Settings support
+- `PluginActionId` attribute for action UUID mapping
+- Stream Deck+ support (keys, dials, and combined)
-1. Create a class that inherits the PluginBase abstract class.
-Implement your logic, focusing on the methods provided in the base class.
-Follow the samples [here][1] for more details
-**New:** In version 2.x - use the `PluginActionId` attribute to indicate the action UUID associated with this class (must match the UUID set in the manifest file)
+## Migrating from v6.x
-~~~~
-[PluginActionId("plugin.uuid.from.manifest.file")]
-public class MyPlugin : PluginBase
-{
- // Create this constructor in your plugin and pass the objects to the PluginBase class
- public MyPlugin(SDConnection connection, InitialPayload payload) : base(connection, payload)
- {
- ....
- // TODO: Use the payload.Settings to see the various settings set in the Property Inspector (in my samples, I create a private class that holds the settings)
- // Other relevant settings in the payload include the actual position of the plugin on the Stream Deck
-
- // Note: By passing the `connection` object back to the PluginBase (using the `base` in the constructor), you now have access to a property called `Connection`
- // throughout your plugin.
- }
- ....
-
- // TODO: Implement all the remaining abstract functions from PluginBase (or just leave them empty if you don't need them)
-
- // An example of how easy it is to populate settings in StreamDeck-Tools v2
- public override void ReceivedSettings(ReceivedSettingsPayload payload)
- {
- Tools.AutoPopulateSettings(settings, payload.Settings); // "settings" is a private class that holds the settings for your plugin's instance.
- }
-}
-~~~~
-
-2. In your program.cs, just pass the args you received to the SDWrapper.Run() function, and you're done!
-**Note:** This process is much easier than the one used in 1.x and is based on using the `PluginActionId` attribute, as shown in Step 1 above.
-Example:
-~~~~
-class Program
-{
- static void Main(string[] args)
- {
- SDWrapper.Run(args);
- }
-}
-~~~~
+See the full [Migration Guide](https://github.com/BarRaider/streamdeck-tools/blob/master/MigrateTo7.0.md) for type mapping tables, code recipes, and step-by-step instructions.
-3. There is no step 3 - that's it! The abstract functions from PluginBase that are implemented in MyPlugin hold all the basics needed for a plugin to work. You can always listen to additional events using the `Connection` property.
+## Resources
-[1]: https://github.com/BarRaider/streamdeck-tools/blob/master/samples.md
\ No newline at end of file
+- [GitHub Repository](https://github.com/BarRaider/streamdeck-tools)
+- [Wiki](https://github.com/BarRaider/streamdeck-tools/wiki)
+- [Sample Plugins](https://github.com/BarRaider/streamdeck-tools/blob/master/samples.md)
+- [EasyPI](https://github.com/BarRaider/streamdeck-easypi)
+- [Discord](http://discord.barraider.com)
diff --git a/README.md b/README.md
index b7943db..c87e190 100644
--- a/README.md
+++ b/README.md
@@ -6,15 +6,18 @@
**Author's website and contact information:** [https://barraider.com](https://barraider.com)
-# Version 7.0 now in Beta!
-- **Cross-platform support** -- plugins can now run on Windows and macOS
-- **SkiaSharp graphics** -- new `SkiaTools`, `SkiaGraphicsTools`, and `SkiaExtensionMethods` classes provide cross-platform drawing APIs
-- **New `SetImageAsync` overloads** -- accept `SKBitmap` or `byte[]` (PNG) in addition to base64 strings
-- **SkiaSharp properties on `TitleParameters`** -- `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()`
-- **.NET 10 target** added (`netstandard2.0`, `net48`, `net8.0`, `net10.0`)
-- All System.Drawing APIs marked `[Obsolete]` with pointers to SkiaSharp replacements (still fully functional on Windows)
-
-**Migration guide:** See [MigrateTo7.0.md](MigrateTo7.0.md) for step-by-step upgrade instructions, type mapping tables, and code recipes. This file is also designed to work with AI coding assistants (Cursor, Copilot, etc.) -- point your AI at it and ask it to migrate your plugin.
+# Introducing Stream Deck Tools Version 7.0
+- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS.
+- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0.
+- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance.
+- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`.
+- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`).
+- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning.
+- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering.
+- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`.
+
+## Migration Guide:
+- See **[Migration Guide](MigrateTo7.0.md)**: for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you).
# Stream Deck+ Support
Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials)
@@ -43,8 +46,18 @@ Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) p
# Change Log
-### Version 7.0 (Beta)
-- See [Version 7.0 now in Beta!](#version-70-now-in-beta) above
+### Version 7.0
+- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS.
+- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0.
+- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance.
+- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`.
+- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`).
+- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead. On Windows, `FontFamily` still works but produces an `[Obsolete]` compiler warning.
+- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering.
+- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`.
+
+## Migration Guide:
+- See **[Migration Guide](MigrateTo7.0.md)**: for the full migration guide with code recipes and API mapping tables. (**Pro-Tip:** Use this file to let Cursor/Claude do the migration for you).
### Version 6.4
- Support for Stream Deck Plus XL, Galleon 100 SD
diff --git a/SamplePlugin/App.config b/SamplePlugin/App.config
index 9395f24..948bea0 100644
--- a/SamplePlugin/App.config
+++ b/SamplePlugin/App.config
@@ -1,13 +1,13 @@
-
+
-
+
-
+
diff --git a/SamplePlugin/PluginAction.cs b/SamplePlugin/PluginAction.cs
index 3c4a7bc..f57c428 100644
--- a/SamplePlugin/PluginAction.cs
+++ b/SamplePlugin/PluginAction.cs
@@ -1,13 +1,10 @@
-using BarRaider.SdTools;
+using BarRaider.SdTools;
using BarRaider.SdTools.Wrappers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using SkiaSharp;
using System;
-using System.Collections.Generic;
-using System.Drawing;
using System.IO;
-using System.Linq;
-using System.Text;
using System.Threading.Tasks;
namespace SamplePlugin
@@ -109,28 +106,21 @@ public override void Dispose()
public async override void KeyPressed(KeyPayload payload)
{
Logger.Instance.LogMessage(TracingLevel.INFO, "Key Pressed");
- TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle);
- using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics))
+ using (SKBitmap image = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas))
{
- graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height);
- graphics.AddTextPath(tp, image.Height, image.Width, "Test");
- graphics.Dispose();
-
+ canvas.Clear(SKColors.DarkBlue);
+ using var font = SkiaTools.CreateFont("Arial", 18, SKFontStyle.Bold);
+ using var paint = new SKPaint { Color = SKColors.White, IsAntialias = true };
+ float x = canvas.GetTextCenter("Pressed!", image.Width, font);
+ canvas.DrawTextLine("Pressed!", font, paint, new SKPoint(x, image.Height / 2f - 10));
+ canvas.Dispose();
await Connection.SetImageAsync(image);
}
}
- public async override void KeyReleased(KeyPayload payload)
+ public async override void KeyReleased(KeyPayload payload)
{
- TitleParameters tp = new TitleParameters(new FontFamily("Arial"), FontStyle.Bold, 20, Color.White, true, TitleVerticalAlignment.Middle);
- using (Image image = Tools.GenerateGenericKeyImage(out Graphics graphics))
- {
- graphics.FillRectangle(new SolidBrush(Color.White), 0, 0, image.Width, image.Height);
- graphics.AddTextPath(tp, image.Height, image.Width, "Test", Color.Black, 7);
- graphics.Dispose();
-
- await Connection.SetImageAsync(image);
- }
+ await Connection.SetDefaultImageAsync();
}
public override void OnTick() { }
@@ -152,4 +142,4 @@ private Task SaveSettings()
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/SamplePlugin/SamplePlugin.csproj b/SamplePlugin/SamplePlugin.csproj
index ac5cff0..9d45018 100644
--- a/SamplePlugin/SamplePlugin.csproj
+++ b/SamplePlugin/SamplePlugin.csproj
@@ -1,7 +1,7 @@
-
+
Exe
- net472
+ net10.0
false
com.test.sdtools
AnyCPU;x64
@@ -19,7 +19,6 @@
bin\Debug\com.test.sdtools.sdPlugin\
-
PreserveNewest
@@ -87,4 +86,4 @@
-
\ No newline at end of file
+
diff --git a/barraider-sdtools/Backend/ISDConnection.cs b/barraider-sdtools/Backend/ISDConnection.cs
index c26df97..9add43a 100644
--- a/barraider-sdtools/Backend/ISDConnection.cs
+++ b/barraider-sdtools/Backend/ISDConnection.cs
@@ -1,7 +1,8 @@
-using BarRaider.SdTools.Events;
+using BarRaider.SdTools.Events;
using BarRaider.SdTools.Wrappers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -102,8 +103,29 @@ public interface ISDConnection : IDisposable
/// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
/// Should image be sent even if it is identical to the one sent previously. Default is false
///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SetImageAsync(SKBitmap, ...) or SetImageAsync(byte[], ...) instead.")]
Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false);
+ ///
+ /// Sets an image on the StreamDeck key from raw PNG bytes.
+ /// Prefer this over the Image overload to avoid System.Drawing dependencies.
+ ///
+ /// PNG-encoded image bytes
+ /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ /// Should image be sent even if it is identical to the one sent previously. Default is false
+ ///
+ Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool forceSendToStreamdeck = false);
+
+ ///
+ /// Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp).
+ /// The bitmap is PNG-encoded and sent as base64.
+ ///
+ /// SkiaSharp bitmap to display on the key
+ /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ /// Should image be sent even if it is identical to the one sent previously. Default is false
+ ///
+ Task SetImageAsync(SKBitmap image, int? state = null, bool forceSendToStreamdeck = false);
+
///
/// Sets the default image for this state, as configured in the manifest
///
diff --git a/barraider-sdtools/Backend/SDConnection.cs b/barraider-sdtools/Backend/SDConnection.cs
index 16c706b..026cd38 100644
--- a/barraider-sdtools/Backend/SDConnection.cs
+++ b/barraider-sdtools/Backend/SDConnection.cs
@@ -1,5 +1,6 @@
-using Newtonsoft.Json;
+using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using SkiaSharp;
using System;
using System.Drawing;
using System.Threading.Tasks;
@@ -10,8 +11,6 @@
using BarRaider.SdTools.Communication;
using BarRaider.SdTools.Communication.SDEvents;
using System.Collections.Generic;
-using NLog.Layouts;
-
namespace BarRaider.SdTools
{
///
@@ -22,6 +21,9 @@ public class SDConnection : ISDConnection
#region Private Members
private string previousImageHash = null;
+ private readonly object imageHashLock = new object();
+ private const int MAX_TITLE_RETRY_ATTEMPTS = 5;
+ private int titleRetryCount = 0;
[JsonIgnore]
private readonly string actionId;
@@ -141,6 +143,10 @@ public SDConnection(StreamDeckConnection connection, string pluginUUID, StreamDe
///
public void Dispose()
{
+ if (StreamDeckConnection == null)
+ {
+ return;
+ }
StreamDeckConnection.OnSendToPlugin -= Connection_OnSendToPlugin;
StreamDeckConnection.OnTitleParametersDidChange -= Connection_OnTitleParametersDidChange;
StreamDeckConnection.OnApplicationDidTerminate -= Connection_OnApplicationDidTerminate;
@@ -238,9 +244,17 @@ public async Task GetGlobalSettingsAsync()
public async Task SetImageAsync(string base64Image, int? state = null, bool forceSendToStreamdeck = false)
{
string hash = Tools.StringToSHA512(base64Image);
- if (forceSendToStreamdeck || hash != previousImageHash)
+ bool shouldSend;
+ lock (imageHashLock)
+ {
+ shouldSend = forceSendToStreamdeck || hash != previousImageHash;
+ if (shouldSend)
+ {
+ previousImageHash = hash;
+ }
+ }
+ if (shouldSend)
{
- previousImageHash = hash;
await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state);
}
}
@@ -252,16 +266,79 @@ public async Task SetImageAsync(string base64Image, int? state = null, bool forc
/// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
/// Should image be sent even if it is identical to the one sent previously. Default is false
///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SetImageAsync(SKBitmap, ...) or SetImageAsync(byte[], ...) instead.")]
public async Task SetImageAsync(Image image, int? state = null, bool forceSendToStreamdeck = false)
{
- string hash = Tools.ImageToSHA512(image);
- if (forceSendToStreamdeck || hash != previousImageHash)
+ string base64Image = Tools.ImageToBase64(image, true);
+ string hash = Tools.StringToSHA512(base64Image);
+ bool shouldSend;
+ lock (imageHashLock)
+ {
+ shouldSend = forceSendToStreamdeck || hash != previousImageHash;
+ if (shouldSend)
+ {
+ previousImageHash = hash;
+ }
+ }
+ if (shouldSend)
{
- previousImageHash = hash;
- await StreamDeckConnection.SetImageAsync(image, ContextId, SDKTarget.HardwareAndSoftware, state);
+ await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state);
}
}
+ ///
+ /// Sets an image on the StreamDeck key from raw PNG bytes.
+ /// Prefer this over the Image overload to avoid System.Drawing dependencies.
+ ///
+ /// PNG-encoded image bytes
+ /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ /// Should image be sent even if it is identical to the one sent previously. Default is false
+ ///
+ public async Task SetImageAsync(byte[] pngImageBytes, int? state = null, bool forceSendToStreamdeck = false)
+ {
+ if (pngImageBytes == null)
+ {
+ await SetDefaultImageAsync();
+ return;
+ }
+
+ string base64Image = "data:image/png;base64," + Convert.ToBase64String(pngImageBytes);
+ string hash = Tools.StringToSHA512(base64Image);
+ bool shouldSend;
+ lock (imageHashLock)
+ {
+ shouldSend = forceSendToStreamdeck || hash != previousImageHash;
+ if (shouldSend)
+ {
+ previousImageHash = hash;
+ }
+ }
+ if (shouldSend)
+ {
+ await StreamDeckConnection.SetImageAsync(base64Image, ContextId, SDKTarget.HardwareAndSoftware, state);
+ }
+ }
+
+ ///
+ /// Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp).
+ /// The bitmap is PNG-encoded and sent as base64.
+ ///
+ /// SkiaSharp bitmap to display on the key
+ /// A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ /// Should image be sent even if it is identical to the one sent previously. Default is false
+ ///
+ public async Task SetImageAsync(SKBitmap image, int? state = null, bool forceSendToStreamdeck = false)
+ {
+ if (image == null)
+ {
+ await SetDefaultImageAsync();
+ return;
+ }
+
+ byte[] pngBytes = image.ToPngByteArray();
+ await SetImageAsync(pngBytes, state, forceSendToStreamdeck);
+ }
+
///
/// Sets the default image for this state, as configured in the manifest
///
@@ -452,11 +529,11 @@ private void Connection_OnTitleParametersDidChange(object sender, SDEventReceive
{
if (e.Event.Context == ContextId)
{
- // Special case to take into account that TitleParameters arrives right after an OnWillAppear
if (OnTitleParametersDidChange == null)
{
- if (sender != this)
+ if (titleRetryCount < MAX_TITLE_RETRY_ATTEMPTS)
{
+ titleRetryCount++;
Task.Run(async () =>
{
await Task.Delay(1000);
@@ -466,6 +543,7 @@ private void Connection_OnTitleParametersDidChange(object sender, SDEventReceive
return;
}
+ titleRetryCount = 0;
var payload = e.Event.Payload;
var newPayload = new TitleParametersPayload(payload.Settings, payload.Coordinates, payload.State, payload.Title, payload.TitleParameters);
OnTitleParametersDidChange?.Invoke(this, new SDEventReceivedEventArgs(new TitleParametersDidChange(e.Event.Action, e.Event.Context, e.Event.Device, newPayload)));
diff --git a/barraider-sdtools/Communication/StreamDeckConnection.cs b/barraider-sdtools/Communication/StreamDeckConnection.cs
index a465798..3917cf5 100644
--- a/barraider-sdtools/Communication/StreamDeckConnection.cs
+++ b/barraider-sdtools/Communication/StreamDeckConnection.cs
@@ -1,13 +1,10 @@
-using BarRaider.SdTools.Communication.Messages;
+using BarRaider.SdTools.Communication.Messages;
using BarRaider.SdTools.Communication.SDEvents;
using BarRaider.SdTools.Wrappers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
-using System.Drawing;
-using System.Drawing.Imaging;
-using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
@@ -177,7 +174,7 @@ internal Task SendAsync(IMessage message)
{
Logger.Instance.LogMessage(TracingLevel.ERROR, $"{this.GetType()} SDTools SendAsync Exception: {ex}");
}
- return null;
+ return Task.CompletedTask;
}
#region Requests
@@ -192,27 +189,6 @@ internal Task LogMessageAsync(string message)
return SendAsync(new LogMessage(message));
}
- internal Task SetImageAsync(Image image, string context, SDKTarget target, int? state)
- {
- try
- {
- using (MemoryStream memoryStream = new MemoryStream())
- {
- image.Save(memoryStream, ImageFormat.Png);
- byte[] imageBytes = memoryStream.ToArray();
-
- // Convert byte[] to Base64 String
- string base64String = $"data:image/png;base64,{Convert.ToBase64String(imageBytes)}";
- return SetImageAsync(base64String, context, target, state);
- }
- }
- catch (Exception ex)
- {
- Logger.Instance.LogMessage(TracingLevel.ERROR, $"{this.GetType()} SetImageAsync Exception: {ex}");
- }
- return null;
- }
-
internal Task SetImageAsync(string base64Image, string context, SDKTarget target, int? state)
{
return SendAsync(new SetImageMessage(base64Image, context, target, state));
@@ -264,6 +240,10 @@ internal Task SwitchToProfileAsync(string device, string profileName, string con
}
internal Task OpenUrlAsync(string uri)
{
+ if (string.IsNullOrEmpty(uri))
+ {
+ throw new ArgumentNullException(nameof(uri));
+ }
return OpenUrlAsync(new Uri(uri));
}
@@ -460,6 +440,7 @@ private async Task DisconnectAsync()
}
OnDisconnected?.Invoke(this, EventArgs.Empty);
+ cancelTokenSource.Dispose();
}
}
diff --git a/barraider-sdtools/Internal/IImageCodec.cs b/barraider-sdtools/Internal/IImageCodec.cs
new file mode 100644
index 0000000..526982c
--- /dev/null
+++ b/barraider-sdtools/Internal/IImageCodec.cs
@@ -0,0 +1,16 @@
+using System.Drawing;
+using System.IO;
+
+namespace BarRaider.SdTools.Internal
+{
+ ///
+ /// Internal image codec abstraction used by compatibility adapters.
+ ///
+ internal interface IImageCodec
+ {
+ byte[] EncodeToPngBytes(Image image);
+ Image DecodeFromBytes(byte[] imageBytes);
+ Image DecodeFromFile(string filePath);
+ Image DecodeFromStream(Stream stream);
+ }
+}
diff --git a/barraider-sdtools/Internal/ImageCodecProvider.cs b/barraider-sdtools/Internal/ImageCodecProvider.cs
new file mode 100644
index 0000000..c8c229e
--- /dev/null
+++ b/barraider-sdtools/Internal/ImageCodecProvider.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace BarRaider.SdTools.Internal
+{
+ internal static class ImageCodecProvider
+ {
+ private static readonly Lazy lazyInstance =
+ new Lazy(() => new SystemDrawingImageCodec());
+
+ internal static IImageCodec Instance => lazyInstance.Value;
+ }
+}
diff --git a/barraider-sdtools/Internal/SkiaSharpImageCodec.cs b/barraider-sdtools/Internal/SkiaSharpImageCodec.cs
new file mode 100644
index 0000000..5d2f03d
--- /dev/null
+++ b/barraider-sdtools/Internal/SkiaSharpImageCodec.cs
@@ -0,0 +1,52 @@
+using SkiaSharp;
+using System.IO;
+
+namespace BarRaider.SdTools.Internal
+{
+ internal sealed class SkiaSharpImageCodec
+ {
+ public byte[] EncodeToPngBytes(SKBitmap bitmap)
+ {
+ if (bitmap == null)
+ {
+ return null;
+ }
+
+ using (SKImage image = SKImage.FromBitmap(bitmap))
+ using (SKData data = image.Encode(SKEncodedImageFormat.Png, 100))
+ {
+ return data.ToArray();
+ }
+ }
+
+ public SKBitmap DecodeFromBytes(byte[] imageBytes)
+ {
+ if (imageBytes == null || imageBytes.Length == 0)
+ {
+ return null;
+ }
+
+ return SKBitmap.Decode(imageBytes);
+ }
+
+ public SKBitmap DecodeFromFile(string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ return null;
+ }
+
+ return SKBitmap.Decode(filePath);
+ }
+
+ public SKBitmap DecodeFromStream(Stream stream)
+ {
+ if (stream == null)
+ {
+ return null;
+ }
+
+ return SKBitmap.Decode(stream);
+ }
+ }
+}
diff --git a/barraider-sdtools/Internal/SystemDrawingImageCodec.cs b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs
new file mode 100644
index 0000000..bbae05f
--- /dev/null
+++ b/barraider-sdtools/Internal/SystemDrawingImageCodec.cs
@@ -0,0 +1,91 @@
+using BarRaider.SdTools.Wrappers;
+using System;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+
+namespace BarRaider.SdTools.Internal
+{
+ ///
+ /// Compatibility image codec implementation backed by System.Drawing.
+ /// Image.FromStream requires the backing stream to remain open for the
+ /// lifetime of the Image, so DecodeFromBytes copies pixel data into a
+ /// new Bitmap and disposes the intermediate resources.
+ ///
+ internal sealed class SystemDrawingImageCodec : IImageCodec
+ {
+ public byte[] EncodeToPngBytes(Image image)
+ {
+ if (image == null)
+ {
+ return null;
+ }
+
+ using (var memoryStream = new MemoryStream())
+ {
+ image.Save(memoryStream, ImageFormat.Png);
+ return memoryStream.ToArray();
+ }
+ }
+
+ public Image DecodeFromBytes(byte[] imageBytes)
+ {
+ if (imageBytes == null || imageBytes.Length == 0)
+ {
+ return null;
+ }
+
+ var memoryStream = new MemoryStream(imageBytes);
+ Image original = null;
+ try
+ {
+ original = Image.FromStream(memoryStream);
+ var copy = new Bitmap(original);
+ original.Dispose();
+ memoryStream.Dispose();
+ return copy;
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SystemDrawingImageCodec.DecodeFromBytes failed: {ex}");
+ original?.Dispose();
+ memoryStream.Dispose();
+ throw;
+ }
+ }
+
+ public Image DecodeFromFile(string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath))
+ {
+ return null;
+ }
+
+ using (Image original = Image.FromFile(filePath))
+ {
+ return new Bitmap(original);
+ }
+ }
+
+ public Image DecodeFromStream(Stream stream)
+ {
+ if (stream == null)
+ {
+ return null;
+ }
+
+ Image original = Image.FromStream(stream);
+ try
+ {
+ var copy = new Bitmap(original);
+ original.Dispose();
+ return copy;
+ }
+ catch
+ {
+ original.Dispose();
+ throw;
+ }
+ }
+ }
+}
diff --git a/barraider-sdtools/README.md b/barraider-sdtools/README.md
new file mode 100644
index 0000000..3d4e7d0
--- /dev/null
+++ b/barraider-sdtools/README.md
@@ -0,0 +1,98 @@
+# BarRaider's Stream Deck Tools
+
+#### C# library that wraps all the communication with the Stream Deck App, allowing you to focus on actually writing the Plugin's logic.
+
+[](https://github.com/BarRaider/streamdeck-tools/actions/workflows/dotnetcore.yml) [](https://www.nuget.org/packages/streamdeck-tools)
+
+**Author's website and contact information:** [https://barraider.com](https://barraider.com)
+
+# Stream Deck+ Support
+Instead of `PluginBase`, Derive from either `KeypadBase` (if you don't support dials), `EncoderBase` (for only dials), `KeyAndEncoderBase` (for both keys and dials)
+
+# Getting Started
+Introducing our new [wiki](https://github.com/BarRaider/streamdeck-tools/wiki) packed with usage instructions, examples and more.
+
+# Dev Discussions / Support
+**Discord:** Discuss in #developers-chat in [Bar Raiders](http://discord.barraider.com)
+
+## Downloadable Resources
+* [StreamDeck-Tools Template](https://github.com/BarRaider/streamdeck-tools/raw/master/utils/StreamDeck-Tools%20Template.vsix) for Visual Studio (2019/2022) - Automatically creates a project with all the files needed to compile a plugin. This is the best way to start a new plugin!
+* [Install.bat](https://github.com/BarRaider/streamdeck-tools/blob/master/utils/install.bat) - Script that quickly uninstalls and reinstalls your plugin on the streamdeck (edit the batch file for more details). Put the install.bat file in your BIN folder (same folder that has Debug/Release sub-folders)
+* [EasyPI](https://github.com/BarRaider/streamdeck-easypi) - Additional library used to easily pass information from the PI (Property Inspector) to your plugin.
+* [Profiles](https://barraider.com/profiles) Downloadable empty profiles for the XL (32-key), Classic (15-key), Mini (6-key) and Mobile devices at https://barraider.com/profiles
+
+## Library Features
+- Encapsulates all the communicating with the Stream Deck, getting a plugin working on the Stream Deck only requires implementing the PluginBase class.
+- Sample plugin now included in this project on Github
+- Built-in integration with NLog. Use `Logger.LogMessage()` for logging.
+- Auto-populate user settings which were modified by the Property Inspector
+- Access the Global Settings from anywhere in your code
+- Simplified working with filenames from the Stream Deck SDK.
+- `PluginActionId` attribute let's you easily associate your code to a specific action defined in the manifest.json
+- Large set of helper functions to simplify creating images and sending them to the Stream Deck.
+
+# Change Log
+
+### Version 7.0
+- **Cross-platform support**: New SkiaSharp-based API surface (`SkiaTools`, `SkiaGraphicsTools`, `SkiaExtensionMethods`) for Windows + macOS.
+- **Target frameworks**: netstandard2.0, net48, net8.0, net10.0.
+- All `System.Drawing`-based APIs marked `[Obsolete]` with migration guidance.
+- New `SetImageAsync(SKBitmap)` and `SetImageAsync(byte[])` overloads on `ISDConnection`.
+- New `DrawTextLine` extension method on `SKCanvas` with Y-as-top semantics (matching `System.Drawing.Graphics.DrawString`).
+- **Breaking change (macOS):** `TitleParameters.FontFamily` throws `PlatformNotSupportedException` on non-Windows. Use `TitleParameters.FontFamilyName` (string) or `TitleParameters.TitleTypeface` (SKTypeface) instead.
+- New `TitleParameters.FontFamilyName`, `TitleSKColor`, `TitleTypeface`, `FontStyleToSKFontStyle()` for cross-platform rendering.
+- `PluginBase` is now `[Obsolete]` -- use `KeypadBase`, `EncoderBase`, or `KeyAndEncoderBase`.
+- See [MigrateTo7.0.md](MigrateTo7.0.md) for the full migration guide with code recipes and API mapping tables.
+
+### Version 6.4
+- Support for Stream Deck Plus XL, Galleon 100 SD
+
+### Version 6.3
+- Support for new Stream Deck types
+
+### Version 6.2
+- Support for .NET 8.0
+
+### Version 6.1
+- Support for new `DialDown` and `DialUp` events.
+- Removed support for deprecated `DialPress` event
+
+### Version 6.0
+1. Merged streamdeck-client-csharp package into library to allow better logging of errors
+2. Added support for SD+ SDK
+3. Increased timeout of connection to Stream Deck due to the Stream Deck taking longer than before to reply on load
+4. Added error catching to prevent 3rd party plugin exception to impact communication
+
+
+### Version 3.2 is out!
+- Created new `ISDConnection` interface which is now implemented by SDConnection and used by PluginAction.
+- GlobalSettingsManager now has a short delay before calling GetGlobalSettings(), to reduce spamming the Stream Deck SDK.
+- Updated dependencies to latest version
+
+### Version 3.1 is out!
+- Updated Logger class to include process name and thread id
+
+### Version 3.0 is out!
+- Updated file handling in `Tools.AutoPopulateSettings` and `Tools.FilenameFromPayload` methods
+- Removed obsolete MD5 functions, use SHA512 functions instead
+- `Tools.CenterText` function now has optional out `textFitsImage` value to verify the text does not exceed the image width
+- New `Tools.FormatBytes` function converts bytes to human-readable value
+- New `Graphics.GetFontSizeWhereTextFitsImage` function helps locate the best size for a text to fit an image on 1 line
+- Updated dependency packages to latest versions
+- Bug fix where FileNameProperty attribute
+
+### Version 2.7 is out!
+- Fully wrapped all Stream Deck events (All part of the SDConneciton class). See ***"Subscribing to events"*** section below
+- Added extension methods for multiple classes related to brushes/colors
+- Added additional methods under the Tools class, including AddTextPathToGraphics which can be used to correctly position text on a key image based on the Text Settings in the Property Inspector see ***"Showing Title based on settings from Property Inspector"*** section below.
+- Additional error checking
+- Updated dependency packages to latest versions
+- Sample plugin now included in this project on Github
+
+### 2019-11-17
+- Updated Install.bat (above) to newer version
+
+### Version 2.6 is out!
+- Added new MD5 functions in the `Tools` helper class
+- Optimized SetImage to not resubmit an image that was just posted to the device. Can be overridden with new property in Connection.SetImage() function.
+
diff --git a/barraider-sdtools/Tools/ExtensionMethods.cs b/barraider-sdtools/Tools/ExtensionMethods.cs
index d4855c0..f12dbd2 100644
--- a/barraider-sdtools/Tools/ExtensionMethods.cs
+++ b/barraider-sdtools/Tools/ExtensionMethods.cs
@@ -1,4 +1,5 @@
-using BarRaider.SdTools.Wrappers;
+using BarRaider.SdTools.Internal;
+using BarRaider.SdTools.Wrappers;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -41,6 +42,7 @@ public static bool IsCoordinatesSame(this KeyCoordinates coordinates, KeyCoordin
///
///
///
+ [Obsolete("Uses System.Drawing.Color which is not cross-platform. Use SKColor directly instead.")]
public static string ToHex(this Color color)
{
return string.Format("#{0:X2}{1:X2}{2:X2}", color.R, color.G, color.B);
@@ -51,6 +53,7 @@ public static string ToHex(this Color color)
///
///
///
+ [Obsolete("Uses System.Drawing.Brush which is not cross-platform. Use SKPaint.Color directly instead.")]
public static string ToHex(this Brush brush)
{
if (brush is SolidBrush solidBrush)
@@ -65,10 +68,11 @@ public static string ToHex(this Brush brush)
#region Image/Graphics
///
- /// Converts an Image into a Byte Array
+ /// Converts an Image into a BMP Byte Array.
///
///
///
+ [Obsolete("ToByteArray encodes as BMP which will not be supported in future versions. Use ToPngByteArray instead.")]
public static byte[] ToByteArray(this Image image)
{
using (var ms = new MemoryStream())
@@ -78,12 +82,24 @@ public static byte[] ToByteArray(this Image image)
}
}
+ ///
+ /// Converts an Image into a PNG Byte Array using the internal codec abstraction.
+ ///
+ ///
+ ///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SKBitmap.ToPngByteArray() from SkiaExtensionMethods instead.")]
+ public static byte[] ToPngByteArray(this Image image)
+ {
+ return ImageCodecProvider.Instance.EncodeToPngBytes(image);
+ }
+
///
/// Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function
///
///
///
///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SKBitmap.ToBase64() from SkiaExtensionMethods instead.")]
public static string ToBase64(this Image image, bool addHeaderPrefix)
{
return Tools.ImageToBase64(image, addHeaderPrefix);
@@ -98,6 +114,7 @@ public static string ToBase64(this Image image, bool addHeaderPrefix)
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.DrawAndMeasureString() from SkiaExtensionMethods instead.")]
public static float DrawAndMeasureString(this Graphics graphics, string text, Font font, Brush brush, PointF position)
{
SizeF stringSize = graphics.MeasureString(text, font);
@@ -117,6 +134,7 @@ public static float DrawAndMeasureString(this Graphics graphics, string text, Fo
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetTextCenter() from SkiaExtensionMethods instead.")]
public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, out bool textFitsImage, int minIndentation = 0)
{
SizeF stringSize = graphics.MeasureString(text, font);
@@ -140,6 +158,7 @@ public static float GetTextCenter(this Graphics graphics, string text, int image
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetTextCenter() from SkiaExtensionMethods instead.")]
public static float GetTextCenter(this Graphics graphics, string text, int imageWidth, Font font, int minIndentation = 0)
{
return graphics.GetTextCenter(text, imageWidth, font, out _, minIndentation);
@@ -154,6 +173,7 @@ public static float GetTextCenter(this Graphics graphics, string text, int image
///
/// ///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.GetFontSizeWhereTextFitsImage() from SkiaExtensionMethods instead.")]
public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string text, int imageWidth, Font font, int minimalFontSize = 6)
{
bool textFitsImage;
@@ -184,6 +204,7 @@ public static float GetFontSizeWhereTextFitsImage(this Graphics graphics, string
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.AddTextPath() from SkiaExtensionMethods instead.")]
public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, int pixelsAlignment = 15)
{
AddTextPath(graphics, titleParameters, imageHeight, imageWidth, text, Color.Black, 1, pixelsAlignment);
@@ -200,6 +221,7 @@ public static void AddTextPath(this Graphics graphics, TitleParameters titlePara
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SKCanvas.AddTextPath() from SkiaExtensionMethods instead.")]
public static void AddTextPath(this Graphics graphics, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, Color strokeColor, float strokeThickness, int pixelsAlignment = 15)
{
try
@@ -210,39 +232,44 @@ public static void AddTextPath(this Graphics graphics, TitleParameters titlePara
return;
}
- Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage, titleParameters.FontStyle, GraphicsUnit.Pixel);
- Color color = titleParameters.TitleColor;
- graphics.PageUnit = GraphicsUnit.Pixel;
- float ratio = graphics.DpiY / imageWidth;
- SizeF stringSize = graphics.MeasureString(text, font);
- float textWidth = stringSize.Width * (1 - ratio);
- float textHeight = stringSize.Height * (1 - ratio);
- int stringWidth = 0;
- if (textWidth < imageWidth)
+ using (Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPixelsScaledToDefaultImage, titleParameters.FontStyle, GraphicsUnit.Pixel))
{
- stringWidth = (int)(Math.Abs((imageWidth - textWidth)) / 2) - pixelsAlignment;
- }
+ Color color = titleParameters.TitleColor;
+ graphics.PageUnit = GraphicsUnit.Pixel;
+ float ratio = graphics.DpiY / imageWidth;
+ SizeF stringSize = graphics.MeasureString(text, font);
+ float textWidth = stringSize.Width * (1 - ratio);
+ float textHeight = stringSize.Height * (1 - ratio);
+ int stringWidth = 0;
+ if (textWidth < imageWidth)
+ {
+ stringWidth = (int)(Math.Abs((imageWidth - textWidth)) / 2) - pixelsAlignment;
+ }
- int stringHeight = pixelsAlignment; // Top
- if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle)
- {
- stringHeight = (imageHeight / 2) - pixelsAlignment;
- }
- else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom)
- {
- stringHeight = (int)(Math.Abs((imageHeight - textHeight)) - pixelsAlignment);
- }
+ int stringHeight = pixelsAlignment; // Top
+ if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle)
+ {
+ stringHeight = (imageHeight / 2) - pixelsAlignment;
+ }
+ else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom)
+ {
+ stringHeight = (int)(Math.Abs((imageHeight - textHeight)) - pixelsAlignment);
+ }
- Pen stroke = new Pen(strokeColor, strokeThickness);
- GraphicsPath gpath = new GraphicsPath();
- gpath.AddString(text,
- font.FontFamily,
- (int)font.Style,
- graphics.DpiY * font.SizeInPoints / imageWidth,
- new Point(stringWidth, stringHeight),
- new StringFormat());
- graphics.DrawPath(stroke, gpath);
- graphics.FillPath(new SolidBrush(color), gpath);
+ using (Pen stroke = new Pen(strokeColor, strokeThickness))
+ using (GraphicsPath gpath = new GraphicsPath())
+ using (SolidBrush fillBrush = new SolidBrush(color))
+ {
+ gpath.AddString(text,
+ font.FontFamily,
+ (int)font.Style,
+ graphics.DpiY * font.SizeInPoints / imageWidth,
+ new Point(stringWidth, stringHeight),
+ new StringFormat());
+ graphics.DrawPath(stroke, gpath);
+ graphics.FillPath(fillBrush, gpath);
+ }
+ }
}
catch (Exception ex)
{
@@ -285,6 +312,7 @@ public static string Truncate(this string str, int maxSize)
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use the SkiaExtensionMethods.SplitToFitKey() overload with SKFont instead.")]
public static string SplitToFitKey(this string str, TitleParameters titleParameters, int leftPaddingPixels = 3, int rightPaddingPixels = 3, int imageWidthPixels = 72)
{
try
@@ -295,33 +323,35 @@ public static string SplitToFitKey(this string str, TitleParameters titleParamet
}
int padding = leftPaddingPixels + rightPaddingPixels;
- Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPoints, titleParameters.FontStyle, GraphicsUnit.Pixel);
- StringBuilder finalString = new StringBuilder();
- StringBuilder currentLine = new StringBuilder();
- SizeF currentLineSize;
-
- using (Bitmap img = new Bitmap(imageWidthPixels, imageWidthPixels))
+ using (Font font = new Font(titleParameters.FontFamily, (float)titleParameters.FontSizeInPoints, titleParameters.FontStyle, GraphicsUnit.Pixel))
{
- using (Graphics graphics = Graphics.FromImage(img))
+ StringBuilder finalString = new StringBuilder();
+ StringBuilder currentLine = new StringBuilder();
+ SizeF currentLineSize;
+
+ using (Bitmap img = new Bitmap(imageWidthPixels, imageWidthPixels))
{
- for (int idx = 0; idx < str.Length; idx++)
+ using (Graphics graphics = Graphics.FromImage(img))
{
- currentLine.Append(str[idx]);
- currentLineSize = graphics.MeasureString(currentLine.ToString(), font);
- if (currentLineSize.Width <= img.Width - padding)
- {
- finalString.Append(str[idx]);
- }
- else // Overflow
+ for (int idx = 0; idx < str.Length; idx++)
{
- finalString.Append("\n" + str[idx]);
- currentLine = new StringBuilder(str[idx].ToString());
+ currentLine.Append(str[idx]);
+ currentLineSize = graphics.MeasureString(currentLine.ToString(), font);
+ if (currentLineSize.Width <= img.Width - padding)
+ {
+ finalString.Append(str[idx]);
+ }
+ else // Overflow
+ {
+ finalString.Append("\n" + str[idx]);
+ currentLine = new StringBuilder(str[idx].ToString());
+ }
}
}
}
- }
- return finalString.ToString();
+ return finalString.ToString();
+ }
}
catch (Exception ex)
{
diff --git a/barraider-sdtools/Tools/GraphicsTools.cs b/barraider-sdtools/Tools/GraphicsTools.cs
index bc363e4..7ded692 100644
--- a/barraider-sdtools/Tools/GraphicsTools.cs
+++ b/barraider-sdtools/Tools/GraphicsTools.cs
@@ -1,4 +1,4 @@
-using BarRaider.SdTools.Wrappers;
+using BarRaider.SdTools.Wrappers;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -18,6 +18,7 @@ public static class GraphicsTools
///
///
///
+ [Obsolete("Returns System.Drawing.Color which is not cross-platform. Use SkiaGraphicsTools.ColorFromHex() instead.")]
public static Color ColorFromHex(string hexColor)
{
return System.Drawing.ColorTranslator.FromHtml(hexColor);
@@ -30,6 +31,7 @@ public static Color ColorFromHex(string hexColor)
///
///
///
+ [Obsolete("Returns System.Drawing.Color which is not cross-platform. Use SkiaGraphicsTools.GenerateColorShades() instead.")]
public static Color GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)
{
Color color = ColorFromHex(initialColor);
@@ -61,6 +63,7 @@ public static Color GenerateColorShades(string initialColor, int currentShade, i
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.ResizeImage() instead.")]
public static Image ResizeImage(Image original, int newWidth, int newHeight)
{
if (original == null)
@@ -109,6 +112,7 @@ public static Image ResizeImage(Image original, int newWidth, int newHeight)
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.ExtractRectangle() instead.")]
public static Bitmap ExtractRectangle(Image image, int startX, int startY, int width, int height)
{
Rectangle rec = new Rectangle(startX, startY, width, height);
@@ -124,6 +128,7 @@ public static Bitmap ExtractRectangle(Image image, int startX, int startY, int w
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.CreateOpacityImage() instead.")]
public static Image CreateOpacityImage(Image image, float opacity)
{
try
@@ -173,6 +178,7 @@ public static Image CreateOpacityImage(Image image, float opacity)
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.DrawMultiLinedText() instead.")]
public static Image[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines, Font font, Color backgroundColor, Color textColor, bool expandToNextImage, PointF keyDrawStartingPosition)
{
float currentWidth = keyDrawStartingPosition.X;
@@ -228,6 +234,7 @@ public static Image[] DrawMultiLinedText(string text, int currentTextPosition, i
///
///
///
+ [Obsolete("Uses System.Drawing types which are not cross-platform. Use SkiaGraphicsTools.WrapStringToFitImage() instead.")]
public static string WrapStringToFitImage(string str, TitleParameters titleParameters, int leftPaddingPixels = 5, int rightPaddingPixels = 5, int imageWidthPixels = 72)
{
try
diff --git a/barraider-sdtools/Tools/SkiaExtensionMethods.cs b/barraider-sdtools/Tools/SkiaExtensionMethods.cs
new file mode 100644
index 0000000..a79e0fb
--- /dev/null
+++ b/barraider-sdtools/Tools/SkiaExtensionMethods.cs
@@ -0,0 +1,303 @@
+using BarRaider.SdTools.Internal;
+using BarRaider.SdTools.Wrappers;
+using SkiaSharp;
+using System;
+
+namespace BarRaider.SdTools
+{
+ ///
+ /// SkiaSharp extension methods for SKBitmap and SKCanvas.
+ /// These are the cross-platform equivalents of the System.Drawing extension methods in .
+ ///
+ public static class SkiaExtensionMethods
+ {
+ private static readonly Lazy codec =
+ new Lazy(() => new SkiaSharpImageCodec());
+
+ #region SKBitmap Extensions
+
+ ///
+ /// Converts an SKBitmap to a PNG byte array.
+ ///
+ /// The bitmap to encode.
+ /// PNG-encoded byte array.
+ public static byte[] ToPngByteArray(this SKBitmap bitmap)
+ {
+ return codec.Value.EncodeToPngBytes(bitmap);
+ }
+
+ ///
+ /// Converts an SKBitmap to a Base64 PNG string.
+ ///
+ /// The bitmap to encode.
+ /// Whether to prepend the data URI header.
+ /// Base64-encoded PNG string.
+ public static string ToBase64(this SKBitmap bitmap, bool addHeaderPrefix)
+ {
+ return SkiaTools.ImageToBase64(bitmap, addHeaderPrefix);
+ }
+
+ #endregion
+
+ #region SKCanvas Extensions
+
+ ///
+ /// Draws a line of text treating the Y coordinate as the top of the text box
+ /// (matching System.Drawing.Graphics.DrawString behavior) and returns the Y
+ /// position for the next line, based on the font's recommended line spacing.
+ ///
+ /// The canvas to draw on.
+ /// The text to draw.
+ /// The font for text rendering.
+ /// The paint for color/style.
+ /// The position to draw at (X = left edge, Y = top of text).
+ /// The Y coordinate for the top of the next line.
+ public static float DrawTextLine(this SKCanvas canvas, string text, SKFont font, SKPaint paint, SKPoint position)
+ {
+ float baseline = position.Y + Math.Abs(font.Metrics.Ascent);
+ canvas.DrawText(text, position.X, baseline, font, paint);
+ return position.Y + font.Spacing;
+ }
+
+ ///
+ /// Draws a string on an SKCanvas and returns the Y position for the next line.
+ /// Equivalent to . Position Y is the top of the text, not the baseline.
+ ///
+ /// The canvas to draw on.
+ /// The text to draw.
+ /// The font for text rendering.
+ /// The paint for color/style.
+ /// The position to draw at (X = left edge, Y = top of text).
+ /// The Y coordinate for the top of the next line.
+ public static float DrawAndMeasureString(this SKCanvas canvas, string text, SKFont font, SKPaint paint, SKPoint position)
+ {
+ return canvas.DrawTextLine(text, font, paint, position);
+ }
+
+ ///
+ /// Returns the center X position of a string given the image width and font.
+ ///
+ /// The canvas (used for consistency with the System.Drawing extension signature).
+ /// The text to measure.
+ /// The total image width.
+ /// The font for measurement.
+ /// Set to true if the text fits within the image width.
+ /// Minimum indentation when text overflows.
+ /// The X coordinate to center the text.
+ public static float GetTextCenter(this SKCanvas canvas, string text, int imageWidth, SKFont font, out bool textFitsImage, int minIndentation = 0)
+ {
+ float textWidth = font.MeasureText(text);
+ float stringWidth = minIndentation;
+ textFitsImage = false;
+ if (textWidth < imageWidth)
+ {
+ textFitsImage = true;
+ stringWidth = Math.Abs(imageWidth - textWidth) / 2;
+ }
+ return stringWidth;
+ }
+
+ ///
+ /// Returns the center X position of a string given the image width and font.
+ ///
+ /// The canvas.
+ /// The text to measure.
+ /// The total image width.
+ /// The font for measurement.
+ /// Minimum indentation when text overflows.
+ /// The X coordinate to center the text.
+ public static float GetTextCenter(this SKCanvas canvas, string text, int imageWidth, SKFont font, int minIndentation = 0)
+ {
+ return canvas.GetTextCenter(text, imageWidth, font, out _, minIndentation);
+ }
+
+ ///
+ /// Returns the largest font size at which the text fits within the given image width.
+ ///
+ /// The canvas.
+ /// The text to measure.
+ /// Maximum width in pixels.
+ /// The base font (used for typeface and initial size).
+ /// The smallest acceptable font size.
+ /// The computed font size in the same units as font.Size.
+ public static float GetFontSizeWhereTextFitsImage(this SKCanvas canvas, string text, int imageWidth, SKFont font, int minimalFontSize = 6)
+ {
+ bool textFitsImage;
+ float size = font.Size;
+ using (var variableFont = new SKFont(font.Typeface, size))
+ {
+ do
+ {
+ canvas.GetTextCenter(text, imageWidth, variableFont, out textFitsImage);
+ if (!textFitsImage)
+ {
+ size -= 0.5f;
+ variableFont.Size = size;
+ }
+ }
+ while (!textFitsImage && size > minimalFontSize);
+ }
+ return size;
+ }
+
+ ///
+ /// Adds styled text to an SKCanvas using TitleParameters for layout.
+ /// Emulates the text rendering settings from the Stream Deck Property Inspector.
+ ///
+ /// The canvas to draw on.
+ /// Title parameters from the Stream Deck event.
+ /// Height of the key image.
+ /// Width of the key image.
+ /// The text to draw.
+ /// Alignment offset in pixels.
+ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, int pixelsAlignment = 15)
+ {
+ AddTextPath(canvas, titleParameters, imageHeight, imageWidth, text, SKColors.Black, 1, pixelsAlignment);
+ }
+
+ ///
+ /// Adds styled text to an SKCanvas using TitleParameters for layout, with stroke settings.
+ ///
+ /// The canvas to draw on.
+ /// Title parameters from the Stream Deck event.
+ /// Height of the key image.
+ /// Width of the key image.
+ /// The text to draw.
+ /// Color for the text stroke.
+ /// Thickness of the text stroke.
+ /// Alignment offset in pixels.
+ public static void AddTextPath(this SKCanvas canvas, TitleParameters titleParameters, int imageHeight, int imageWidth, string text, SKColor strokeColor, float strokeThickness, int pixelsAlignment = 15)
+ {
+ try
+ {
+ if (titleParameters == null)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, "SkiaExtensionMethods.AddTextPath: titleParameters is null");
+ return;
+ }
+
+ if (string.IsNullOrEmpty(text))
+ {
+ return;
+ }
+
+ float fontSize = (float)titleParameters.FontSizeInPixelsScaledToDefaultImage;
+ // Match System.Drawing's GraphicsPath.AddString scaling: emSize = DpiY * SizeInPoints / imageWidth
+ // which simplifies to fontSize * 72 / imageWidth (since SizeInPoints = fontSize * 72 / DpiY).
+ float scaledFontSize = fontSize * 72.0f / imageWidth;
+ var typeface = titleParameters.TitleTypeface;
+ var color = titleParameters.TitleSKColor;
+
+ using (var font = new SKFont(typeface, scaledFontSize))
+ {
+ // SKCanvas.DrawText uses the text baseline for Y, while the old
+ // GraphicsPath.AddString used the top-left corner. Shift Y down
+ // by the ascent so the visual position matches the old behavior.
+ float ascent = -font.Metrics.Ascent;
+
+ string[] lines = text.Split('\n');
+ float lineSpacing = font.Spacing;
+ float totalTextHeight = ascent + font.Metrics.Descent + (lines.Length - 1) * lineSpacing;
+
+ float startY;
+ if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Bottom)
+ {
+ startY = imageHeight - pixelsAlignment - totalTextHeight + ascent;
+ }
+ else if (titleParameters.VerticalAlignment == TitleVerticalAlignment.Middle)
+ {
+ startY = (imageHeight - totalTextHeight) / 2f + ascent;
+ }
+ else
+ {
+ startY = pixelsAlignment + ascent;
+ }
+
+ using (var strokePaint = new SKPaint
+ {
+ Color = strokeColor,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = strokeThickness,
+ IsAntialias = true
+ })
+ using (var fillPaint = new SKPaint
+ {
+ Color = color,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ })
+ {
+ float y = startY;
+ foreach (string line in lines)
+ {
+ float textWidth = font.MeasureText(line);
+ float stringWidth = 0;
+ if (textWidth < imageWidth)
+ {
+ stringWidth = (imageWidth - textWidth) / 2f;
+ }
+
+ canvas.DrawText(line, stringWidth, y, font, strokePaint);
+ canvas.DrawText(line, stringWidth, y, font, fillPaint);
+ y += lineSpacing;
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaExtensionMethods.AddTextPath Exception: {ex}");
+ }
+ }
+
+ ///
+ /// Adds line breaks to a string so it fits within the key image when using SetTitleAsync().
+ /// SkiaSharp equivalent of .
+ ///
+ /// The string to split.
+ /// Title parameters for font information.
+ /// The SKFont used for text measurement.
+ /// Left padding in pixels.
+ /// Right padding in pixels.
+ /// Width of the key image in pixels.
+ /// The string with line breaks inserted.
+ public static string SplitToFitKey(this string str, TitleParameters titleParameters, SKFont font, int leftPaddingPixels = 3, int rightPaddingPixels = 3, int imageWidthPixels = 72)
+ {
+ try
+ {
+ if (titleParameters == null || font == null)
+ {
+ return str;
+ }
+
+ int padding = leftPaddingPixels + rightPaddingPixels;
+ var finalString = new System.Text.StringBuilder();
+ var currentLine = new System.Text.StringBuilder();
+
+ for (int idx = 0; idx < str.Length; idx++)
+ {
+ currentLine.Append(str[idx]);
+ float width = font.MeasureText(currentLine.ToString());
+ if (width <= imageWidthPixels - padding)
+ {
+ finalString.Append(str[idx]);
+ }
+ else
+ {
+ finalString.Append("\n" + str[idx]);
+ currentLine = new System.Text.StringBuilder(str[idx].ToString());
+ }
+ }
+
+ return finalString.ToString();
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaExtensionMethods.SplitToFitKey Exception: {ex}");
+ return str;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/barraider-sdtools/Tools/SkiaGraphicsTools.cs b/barraider-sdtools/Tools/SkiaGraphicsTools.cs
new file mode 100644
index 0000000..97fa5e3
--- /dev/null
+++ b/barraider-sdtools/Tools/SkiaGraphicsTools.cs
@@ -0,0 +1,295 @@
+using BarRaider.SdTools.Wrappers;
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+
+namespace BarRaider.SdTools
+{
+ ///
+ /// Cross-platform graphics manipulation utilities using SkiaSharp.
+ /// These methods are the SkiaSharp equivalents of the System.Drawing methods in .
+ ///
+ public static class SkiaGraphicsTools
+ {
+ ///
+ /// Parse a hex color string into an SKColor.
+ ///
+ /// Hex string such as "#FF0000" or "#80FF0000".
+ /// The parsed SKColor.
+ public static SKColor ColorFromHex(string hexColor)
+ {
+ return SkiaTools.ColorFromHex(hexColor);
+ }
+
+ ///
+ /// Generates multiple shades based on an initial color and the number of stages.
+ ///
+ /// Initial hex color string.
+ /// The current shade index (0-based).
+ /// Total number of shade levels.
+ /// A darker shade of the initial color.
+ public static SKColor GenerateColorShades(string initialColor, int currentShade, int totalAmountOfShades)
+ {
+ SKColor color = ColorFromHex(initialColor);
+ double r = color.Red;
+ double g = color.Green;
+ double b = color.Blue;
+
+ if (currentShade == totalAmountOfShades - 1)
+ {
+ currentShade = 1;
+ }
+
+ for (int idx = 0; idx < currentShade; idx++)
+ {
+ r /= 2;
+ g /= 2;
+ b /= 2;
+ }
+
+ return new SKColor((byte)r, (byte)g, (byte)b, color.Alpha);
+ }
+
+ ///
+ /// Resizes an image while maintaining aspect ratio and centering on a black background.
+ ///
+ /// The source bitmap.
+ /// Target width.
+ /// Target height.
+ /// A new resized SKBitmap. Caller must dispose. Returns null if original is null.
+ public static SKBitmap ResizeImage(SKBitmap original, int newWidth, int newHeight)
+ {
+ if (original == null)
+ {
+ return null;
+ }
+
+ int originalWidth = original.Width;
+ int originalHeight = original.Height;
+
+ if (originalWidth == 0 || originalHeight == 0)
+ {
+ return null;
+ }
+
+ double ratioX = (double)newWidth / originalWidth;
+ double ratioY = (double)newHeight / originalHeight;
+ double ratio = Math.Min(ratioX, ratioY);
+
+ float scaledWidth = (float)(originalWidth * ratio);
+ float scaledHeight = (float)(originalHeight * ratio);
+ int posX = (int)((newWidth - scaledWidth) / 2);
+ int posY = (int)((newHeight - scaledHeight) / 2);
+
+ var result = new SKBitmap(newWidth, newHeight);
+ try
+ {
+ using (var canvas = new SKCanvas(result))
+ using (var paint = new SKPaint { IsAntialias = true })
+ {
+ canvas.Clear(SKColors.Black);
+ var destRect = SKRect.Create(posX, posY, scaledWidth, scaledHeight);
+ canvas.DrawBitmap(original, destRect, paint);
+ }
+ }
+ catch
+ {
+ result.Dispose();
+ throw;
+ }
+ return result;
+ }
+
+ ///
+ /// Extracts a rectangular region from an image.
+ ///
+ /// The source bitmap.
+ /// Left edge of the rectangle.
+ /// Top edge of the rectangle.
+ /// Width of the rectangle.
+ /// Height of the rectangle.
+ /// A new SKBitmap with the extracted region. Caller must dispose.
+ public static SKBitmap ExtractRectangle(SKBitmap bitmap, int startX, int startY, int width, int height)
+ {
+ if (bitmap == null)
+ {
+ return null;
+ }
+
+ var subset = new SKRectI(startX, startY, startX + width, startY + height);
+ var result = new SKBitmap();
+ if (bitmap.ExtractSubset(result, subset))
+ {
+ return result;
+ }
+
+ result.Dispose();
+ return null;
+ }
+
+ ///
+ /// Creates a copy of the image with modified opacity.
+ ///
+ /// The source bitmap.
+ /// Opacity value from 0.0 (transparent) to 1.0 (opaque).
+ /// A new SKBitmap with the applied opacity. Caller must dispose.
+ public static SKBitmap CreateOpacityImage(SKBitmap bitmap, float opacity)
+ {
+ if (bitmap == null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var result = new SKBitmap(bitmap.Width, bitmap.Height);
+ try
+ {
+ using (var canvas = new SKCanvas(result))
+ using (var paint = new SKPaint())
+ {
+ byte alpha = (byte)(opacity * 255);
+ var colorFilter = SKColorFilter.CreateBlendMode(
+ new SKColor(255, 255, 255, alpha), SKBlendMode.DstIn);
+ paint.ColorFilter = colorFilter;
+ canvas.Clear(SKColors.Transparent);
+ canvas.DrawBitmap(bitmap, 0, 0, paint);
+ }
+ }
+ catch
+ {
+ result.Dispose();
+ throw;
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaGraphicsTools.CreateOpacityImage exception: {ex}");
+ return null;
+ }
+ }
+
+ ///
+ /// Draws multi-lined text onto key images. Generates one or more images where each has
+ /// text drawn based on the given parameters.
+ ///
+ /// The text to draw.
+ /// Starting character position.
+ /// Maximum characters per line.
+ /// Maximum lines per image.
+ /// The SKFont for text rendering.
+ /// Background fill color.
+ /// Text color.
+ /// If true, overflow text creates additional images.
+ /// Starting draw position on the key.
+ /// Array of SKBitmaps with drawn text. Caller must dispose each bitmap.
+ public static SKBitmap[] DrawMultiLinedText(string text, int currentTextPosition, int lettersPerLine, int numberOfLines,
+ SKFont font, SKColor backgroundColor, SKColor textColor, bool expandToNextImage, SKPoint keyDrawStartingPosition)
+ {
+ if (string.IsNullOrEmpty(text) || font == null)
+ {
+ return Array.Empty();
+ }
+
+ float currentWidth = keyDrawStartingPosition.X;
+ float currentHeight = keyDrawStartingPosition.Y;
+ int currentLine = 0;
+ var images = new List();
+
+ SKBitmap img = SkiaTools.GenerateGenericKeyImage(out SKCanvas canvas);
+ if (img == null)
+ {
+ return Array.Empty();
+ }
+ images.Add(img);
+
+ canvas.Clear(backgroundColor);
+
+ float lineHeight = img.Height / numberOfLines;
+ if (numberOfLines == 1)
+ {
+ currentHeight = img.Height / 2f;
+ }
+
+ float widthIncrement = img.Width / (float)lettersPerLine;
+
+ using (var paint = new SKPaint { Color = textColor, IsAntialias = true })
+ {
+ for (int letter = currentTextPosition; letter < text.Length; letter++)
+ {
+ if (letter > currentTextPosition && letter % lettersPerLine == 0)
+ {
+ currentLine++;
+ if (currentLine >= numberOfLines)
+ {
+ if (expandToNextImage)
+ {
+ var overflow = DrawMultiLinedText(text, letter, lettersPerLine, numberOfLines,
+ font, backgroundColor, textColor, expandToNextImage, keyDrawStartingPosition);
+ images.AddRange(overflow);
+ }
+ break;
+ }
+
+ currentHeight += lineHeight;
+ currentWidth = keyDrawStartingPosition.X;
+ }
+
+ canvas.DrawText(text[letter].ToString(), currentWidth, currentHeight, font, paint);
+ currentWidth += widthIncrement;
+ }
+ }
+
+ canvas.Dispose();
+ return images.ToArray();
+ }
+
+ ///
+ /// Adds line breaks to a string so that it fits within the given image width
+ /// when rendered with the specified font.
+ ///
+ /// The string to wrap.
+ /// The SKFont used for measurement.
+ /// The maximum width in pixels.
+ /// Left padding in pixels.
+ /// Right padding in pixels.
+ /// The string with line breaks inserted.
+ public static string WrapStringToFitImage(string str, SKFont font, int imageWidthPixels = 72, int leftPaddingPixels = 5, int rightPaddingPixels = 5)
+ {
+ try
+ {
+ if (font == null)
+ {
+ return str;
+ }
+
+ int padding = leftPaddingPixels + rightPaddingPixels;
+ var finalString = new System.Text.StringBuilder();
+ var currentLine = new System.Text.StringBuilder();
+
+ for (int idx = 0; idx < str.Length; idx++)
+ {
+ currentLine.Append(str[idx]);
+ float width = font.MeasureText(currentLine.ToString());
+ if (width <= imageWidthPixels - padding)
+ {
+ finalString.Append(str[idx]);
+ }
+ else
+ {
+ finalString.Append("\n" + str[idx]);
+ currentLine = new System.Text.StringBuilder(str[idx].ToString());
+ }
+ }
+
+ return finalString.ToString();
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaGraphicsTools.WrapStringToFitImage Exception: {ex}");
+ return str;
+ }
+ }
+ }
+}
diff --git a/barraider-sdtools/Tools/SkiaTools.cs b/barraider-sdtools/Tools/SkiaTools.cs
new file mode 100644
index 0000000..81836cf
--- /dev/null
+++ b/barraider-sdtools/Tools/SkiaTools.cs
@@ -0,0 +1,230 @@
+using BarRaider.SdTools.Internal;
+using SkiaSharp;
+using System;
+using System.Globalization;
+using System.IO;
+using System.Security.Cryptography;
+
+namespace BarRaider.SdTools
+{
+ ///
+ /// Cross-platform image utilities using SkiaSharp. These methods are the SkiaSharp equivalents
+ /// of the System.Drawing methods in and work on Windows, macOS, and Linux.
+ ///
+ public static class SkiaTools
+ {
+ private const string HEADER_PREFIX = "data:image/png;base64,";
+ private const int CLASSIC_KEY_DEFAULT_HEIGHT = 72;
+ private const int CLASSIC_KEY_DEFAULT_WIDTH = 72;
+ private const int PLUS_KEY_DEFAULT_HEIGHT = 144;
+ private const int PLUS_KEY_DEFAULT_WIDTH = 144;
+ private const int XL_KEY_DEFAULT_HEIGHT = 96;
+ private const int XL_KEY_DEFAULT_WIDTH = 96;
+ private const int GENERIC_KEY_IMAGE_SIZE = 144;
+
+ private static readonly Lazy codec =
+ new Lazy(() => new SkiaSharpImageCodec());
+
+ #region Image Related
+
+ ///
+ /// Generates an empty key bitmap with the default dimensions for the given Stream Deck device type.
+ /// The returned SKCanvas is ready for drawing and must be disposed by the caller.
+ ///
+ /// The Stream Deck device type.
+ /// The SKCanvas for drawing on the bitmap. Caller must dispose.
+ /// An SKBitmap sized for the device. Caller must dispose.
+ public static SKBitmap GenerateKeyImage(DeviceType streamDeckType, out SKCanvas canvas)
+ {
+ int height = Tools.GetKeyDefaultHeight(streamDeckType);
+ int width = Tools.GetKeyDefaultWidth(streamDeckType);
+ return GenerateKeyImage(height, width, out canvas);
+ }
+
+ ///
+ /// Creates a key image that fits all Stream Deck models (144x144).
+ /// The returned SKCanvas is ready for drawing and must be disposed by the caller.
+ ///
+ /// The SKCanvas for drawing on the bitmap. Caller must dispose.
+ /// An SKBitmap. Caller must dispose.
+ public static SKBitmap GenerateGenericKeyImage(out SKCanvas canvas)
+ {
+ return GenerateKeyImage(GENERIC_KEY_IMAGE_SIZE, GENERIC_KEY_IMAGE_SIZE, out canvas);
+ }
+
+ ///
+ /// Convert an SKBitmap to a Base64 PNG string.
+ /// Set addHeaderPrefix to true if sending to SetImageAsync.
+ ///
+ /// The bitmap to encode.
+ /// Whether to prepend the data URI header.
+ /// Base64-encoded PNG string, or null if bitmap is null.
+ public static string ImageToBase64(SKBitmap bitmap, bool addHeaderPrefix)
+ {
+ if (bitmap == null)
+ {
+ return null;
+ }
+
+ byte[] imageBytes = codec.Value.EncodeToPngBytes(bitmap);
+ if (imageBytes == null)
+ {
+ return null;
+ }
+
+ string base64String = Convert.ToBase64String(imageBytes);
+ return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String;
+ }
+
+ ///
+ /// Convert a base64 image string to an SKBitmap.
+ ///
+ /// Base64-encoded image, optionally with a data URI header.
+ /// An SKBitmap, or null on failure. Caller must dispose.
+ public static SKBitmap Base64StringToImage(string base64String)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(base64String))
+ {
+ return null;
+ }
+
+ if (base64String.StartsWith(HEADER_PREFIX, StringComparison.Ordinal))
+ {
+ base64String = base64String.Substring(HEADER_PREFIX.Length);
+ }
+
+ byte[] imageBytes = Convert.FromBase64String(base64String);
+ return codec.Value.DecodeFromBytes(imageBytes);
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.Base64StringToImage Exception: {ex}");
+ }
+ return null;
+ }
+
+ ///
+ /// Convert an image file to a Base64 PNG string.
+ /// Set addHeaderPrefix to true if sending to SetImageAsync.
+ ///
+ /// Path to the image file.
+ /// Whether to prepend the data URI header.
+ /// Base64-encoded PNG string, or null if file doesn't exist.
+ public static string FileToBase64(string fileName, bool addHeaderPrefix)
+ {
+ if (!File.Exists(fileName))
+ {
+ return null;
+ }
+
+ using (SKBitmap bitmap = codec.Value.DecodeFromFile(fileName))
+ {
+ return ImageToBase64(bitmap, addHeaderPrefix);
+ }
+ }
+
+ ///
+ /// Loads an image from a file path. Returns an independent SKBitmap that does not lock the file.
+ ///
+ /// Path to the image file.
+ /// An SKBitmap, or null if the path is invalid or file doesn't exist. Caller must dispose.
+ public static SKBitmap LoadImage(string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
+ {
+ return null;
+ }
+
+ return codec.Value.DecodeFromFile(filePath);
+ }
+
+ ///
+ /// Loads an image from a stream. The caller may close the stream after this returns.
+ ///
+ /// The stream containing image data.
+ /// An SKBitmap, or null if the stream is null. Caller must dispose.
+ public static SKBitmap LoadImage(Stream stream)
+ {
+ return codec.Value.DecodeFromStream(stream);
+ }
+
+ ///
+ /// Returns a SHA512 hash of the PNG-encoded bitmap.
+ ///
+ /// The bitmap to hash.
+ /// Hex-encoded SHA512 hash, or null on failure.
+ public static string ImageToSHA512(SKBitmap bitmap)
+ {
+ if (bitmap == null)
+ {
+ return null;
+ }
+
+ try
+ {
+ byte[] imageBytes = codec.Value.EncodeToPngBytes(bitmap);
+ return imageBytes == null ? null : Tools.BytesToSHA512(imageBytes);
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.ImageToSHA512 Exception: {ex}");
+ }
+ return null;
+ }
+
+ ///
+ /// Creates an SKFont from a font family name, size in points, and optional style.
+ /// The caller is responsible for disposing the returned SKFont.
+ ///
+ /// Font family name (e.g. "Arial", "Verdana").
+ /// Font size in points.
+ /// SkiaSharp font style. Defaults to Normal.
+ /// A new SKFont. Caller must dispose.
+ public static SKFont CreateFont(string familyName, float sizeInPoints, SKFontStyle style = null)
+ {
+ var typeface = SKTypeface.FromFamilyName(familyName, style ?? SKFontStyle.Normal);
+ return new SKFont(typeface, sizeInPoints);
+ }
+
+ ///
+ /// Parses a hex color string (e.g. "#FF0000" or "#AAFF0000") into an SKColor.
+ ///
+ /// Hex color string with leading '#'.
+ /// The parsed SKColor.
+ public static SKColor ColorFromHex(string hexColor)
+ {
+ if (!string.IsNullOrEmpty(hexColor) && SKColor.TryParse(hexColor, out SKColor color))
+ {
+ return color;
+ }
+
+ Logger.Instance.LogMessage(TracingLevel.WARN, $"SkiaTools.ColorFromHex: Failed to parse '{hexColor}', returning black");
+ return SKColors.Black;
+ }
+
+ #endregion
+
+ #region Private Methods
+
+ private static SKBitmap GenerateKeyImage(int height, int width, out SKCanvas canvas)
+ {
+ try
+ {
+ var bitmap = new SKBitmap(width, height);
+ canvas = new SKCanvas(bitmap);
+ canvas.Clear(SKColors.Black);
+ return bitmap;
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"SkiaTools.GenerateKeyImage exception: {ex} Height: {height} Width: {width}");
+ }
+ canvas = null;
+ return null;
+ }
+
+ #endregion
+ }
+}
diff --git a/barraider-sdtools/Tools/Tools.cs b/barraider-sdtools/Tools/Tools.cs
index 2a62b4a..b1a3420 100644
--- a/barraider-sdtools/Tools/Tools.cs
+++ b/barraider-sdtools/Tools/Tools.cs
@@ -1,11 +1,11 @@
-using BarRaider.SdTools.Wrappers;
+using BarRaider.SdTools.Wrappers;
+using BarRaider.SdTools.Internal;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
-using System.Drawing.Imaging;
using System.Drawing.Text;
using System.IO;
using System.Linq;
@@ -26,6 +26,10 @@ public static class Tools
private const int CLASSIC_KEY_DEFAULT_WIDTH = 72;
private const int PLUS_KEY_DEFAULT_HEIGHT = 144;
private const int PLUS_KEY_DEFAULT_WIDTH = 144;
+ private const int PLUS_XL_KEY_DEFAULT_HEIGHT = 144;
+ private const int PLUS_XL_KEY_DEFAULT_WIDTH = 144;
+ private const int NEO_KEY_DEFAULT_HEIGHT = 96;
+ private const int NEO_KEY_DEFAULT_WIDTH = 96;
private const int XL_KEY_DEFAULT_HEIGHT = 96;
private const int XL_KEY_DEFAULT_WIDTH = 96;
private const int GENERIC_KEY_IMAGE_SIZE = 144;
@@ -39,6 +43,7 @@ public static class Tools
///
///
///
+ [Obsolete("Uses System.Drawing which is not cross-platform. Use SkiaTools.FileToBase64() instead.")]
public static string FileToBase64(string fileName, bool addHeaderPrefix)
{
if (!File.Exists(fileName))
@@ -46,18 +51,48 @@ public static string FileToBase64(string fileName, bool addHeaderPrefix)
return null;
}
- using (Image image = Image.FromFile(fileName))
+ using (Image image = ImageCodecProvider.Instance.DecodeFromFile(fileName))
{
return ImageToBase64(image, addHeaderPrefix);
}
}
+ ///
+ /// Loads an image from a file path. Returns an independent copy that does not lock the file.
+ /// Replaces direct usage of Image.FromFile which holds a file lock.
+ ///
+ ///
+ /// An Image, or null if the path is null/empty or the file does not exist.
+ [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.LoadImage() instead.")]
+ public static Image LoadImage(string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
+ {
+ return null;
+ }
+
+ return ImageCodecProvider.Instance.DecodeFromFile(filePath);
+ }
+
+ ///
+ /// Loads an image from a stream. Returns an independent copy; the caller may close the stream after this returns.
+ /// Replaces direct usage of Image.FromStream which requires the stream to remain open.
+ ///
+ ///
+ /// An Image, or null if the stream is null.
+ [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.LoadImage() instead.")]
+ public static Image LoadImage(Stream stream)
+ {
+ return ImageCodecProvider.Instance.DecodeFromStream(stream);
+ }
+
///
/// Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function
///
///
///
///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SkiaTools.ImageToBase64(SKBitmap, bool) instead.")]
public static string ImageToBase64(Image image, bool addHeaderPrefix)
{
if (image == null)
@@ -65,15 +100,15 @@ public static string ImageToBase64(Image image, bool addHeaderPrefix)
return null;
}
- using (MemoryStream m = new MemoryStream())
+ byte[] imageBytes = ImageCodecProvider.Instance.EncodeToPngBytes(image);
+ if (imageBytes == null)
{
- image.Save(m, ImageFormat.Png);
- byte[] imageBytes = m.ToArray();
-
- // Convert byte[] to Base64 String
- string base64String = Convert.ToBase64String(imageBytes);
- return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String;
+ return null;
}
+
+ // Convert byte[] to Base64 String
+ string base64String = Convert.ToBase64String(imageBytes);
+ return addHeaderPrefix ? HEADER_PREFIX + base64String : base64String;
}
///
@@ -81,6 +116,7 @@ public static string ImageToBase64(Image image, bool addHeaderPrefix)
///
///
///
+ [Obsolete("Returns System.Drawing.Image which is not cross-platform. Use SkiaTools.Base64StringToImage() instead.")]
public static Image Base64StringToImage(string base64String)
{
try
@@ -91,16 +127,13 @@ public static Image Base64StringToImage(string base64String)
}
// Remove header
- if (base64String.Substring(0, HEADER_PREFIX.Length) == HEADER_PREFIX)
+ if (base64String.StartsWith(HEADER_PREFIX, StringComparison.Ordinal))
{
base64String = base64String.Substring(HEADER_PREFIX.Length);
}
byte[] imageBytes = Convert.FromBase64String(base64String);
- using (MemoryStream m = new MemoryStream(imageBytes))
- {
- return Image.FromStream(m);
- }
+ return ImageCodecProvider.Instance.DecodeFromBytes(imageBytes);
}
catch (Exception ex)
{
@@ -122,12 +155,15 @@ public static int GetKeyDefaultHeight(DeviceType streamDeckType)
case DeviceType.StreamDeckClassic:
case DeviceType.StreamDeckMini:
case DeviceType.StreamDeckMobile:
- case DeviceType.StreamDeckNeo:
return CLASSIC_KEY_DEFAULT_HEIGHT;
+ case DeviceType.StreamDeckNeo:
+ return NEO_KEY_DEFAULT_HEIGHT;
case DeviceType.StreamDeckXL:
return XL_KEY_DEFAULT_HEIGHT;
case DeviceType.StreamDeckPlus:
return PLUS_KEY_DEFAULT_HEIGHT;
+ case DeviceType.StreamDeckPlusXL:
+ return PLUS_XL_KEY_DEFAULT_HEIGHT;
default:
Logger.Instance.LogMessage(TracingLevel.ERROR, $"SDTools GetKeyDefaultHeight Error: Invalid StreamDeckDeviceType: {streamDeckType}");
break;
@@ -148,12 +184,15 @@ public static int GetKeyDefaultWidth(DeviceType streamDeckType)
case DeviceType.StreamDeckClassic:
case DeviceType.StreamDeckMini:
case DeviceType.StreamDeckMobile:
- case DeviceType.StreamDeckNeo:
return CLASSIC_KEY_DEFAULT_WIDTH;
+ case DeviceType.StreamDeckNeo:
+ return NEO_KEY_DEFAULT_WIDTH;
case DeviceType.StreamDeckXL:
return XL_KEY_DEFAULT_WIDTH;
case DeviceType.StreamDeckPlus:
return PLUS_KEY_DEFAULT_WIDTH;
+ case DeviceType.StreamDeckPlusXL:
+ return PLUS_XL_KEY_DEFAULT_WIDTH;
default:
Logger.Instance.LogMessage(TracingLevel.ERROR, $"SDTools GetKeyDefaultHeight Error: Invalid StreamDeckDeviceType: {streamDeckType}");
break;
@@ -168,6 +207,7 @@ public static int GetKeyDefaultWidth(DeviceType streamDeckType)
///
///
///
+ [Obsolete("Returns System.Drawing types which are not cross-platform. Use SkiaTools.GenerateKeyImage() instead.")]
public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics graphics)
{
int height = GetKeyDefaultHeight(streamDeckType);
@@ -176,11 +216,28 @@ public static Bitmap GenerateKeyImage(DeviceType streamDeckType, out Graphics gr
return GenerateKeyImage(height, width, out graphics);
}
+ ///
+ /// Creates a Font from a family name, size in points, and optional style.
+ /// Prefer this over calling new Font(...) directly, as this helper will be
+ /// adapted to alternative backends in a future release.
+ /// The caller is responsible for disposing the returned Font.
+ ///
+ /// Font family name (e.g. "Arial", "Verdana").
+ /// Font size in points.
+ /// Font style flags. Defaults to Regular.
+ /// A new Font instance. The caller must dispose it when done.
+ [Obsolete("Returns System.Drawing.Font which is not cross-platform. Use SkiaTools.CreateFont() instead.")]
+ public static Font CreateFont(string familyName, float sizeInPoints, FontStyle style = FontStyle.Regular)
+ {
+ return new Font(familyName, sizeInPoints, style, GraphicsUnit.Point);
+ }
+
///
/// Creates a key image that fits all Stream Decks
///
///
///
+ [Obsolete("Returns System.Drawing types which are not cross-platform. Use SkiaTools.GenerateGenericKeyImage() instead.")]
public static Bitmap GenerateGenericKeyImage(out Graphics graphics)
{
return GenerateKeyImage(GENERIC_KEY_IMAGE_SIZE, GENERIC_KEY_IMAGE_SIZE, out graphics);
@@ -198,7 +255,6 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph
try
{
Bitmap bitmap = new Bitmap(width, height);
- var brush = new SolidBrush(Color.Black);
graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
@@ -206,8 +262,10 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
- //Fill background black
- graphics.FillRectangle(brush, 0, 0, width, height);
+ using (var brush = new SolidBrush(Color.Black))
+ {
+ graphics.FillRectangle(brush, 0, 0, width, height);
+ }
return bitmap;
}
catch (Exception ex)
@@ -229,7 +287,11 @@ private static Bitmap GenerateKeyImage(int height, int width, out Graphics graph
///
public static string FilenameFromPayload(Newtonsoft.Json.Linq.JToken payload)
{
- return FilenameFromString((string)payload);
+ if (payload == null || payload.Type == JTokenType.Null || payload.Type == JTokenType.Undefined)
+ {
+ return null;
+ }
+ return FilenameFromString(payload.ToString());
}
private static string FilenameFromString(string filenameWithFakepath)
@@ -302,6 +364,7 @@ public static string FormatBytes(double numberInBytes)
///
///
///
+ [Obsolete("Uses System.Drawing.Image which is not cross-platform. Use SkiaTools.ImageToSHA512(SKBitmap) instead.")]
public static string ImageToSHA512(Image image)
{
if (image == null)
@@ -311,11 +374,8 @@ public static string ImageToSHA512(Image image)
try
{
- using (MemoryStream ms = new MemoryStream())
- {
- image.Save(ms, ImageFormat.Png);
- return BytesToSHA512(ms.ToArray());
- }
+ byte[] imageBytes = ImageCodecProvider.Instance.EncodeToPngBytes(image);
+ return imageBytes == null ? null : BytesToSHA512(imageBytes);
}
catch (Exception ex)
{
@@ -345,6 +405,11 @@ public static string StringToSHA512(string str)
///
public static string BytesToSHA512(byte[] byteStream)
{
+ if (byteStream == null)
+ {
+ return null;
+ }
+
try
{
using (SHA512 sha512 = SHA512.Create())
@@ -392,7 +457,15 @@ public static int AutoPopulateSettings(T toSettings, JObject fromJObject)
}
else
{
- info.SetValue(toSettings, Convert.ChangeType(prop.Value, info.PropertyType));
+ try
+ {
+ info.SetValue(toSettings, Convert.ChangeType(prop.Value, info.PropertyType));
+ }
+ catch (Exception ex)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, $"AutoPopulateSettings: Failed to convert property '{prop.Key}' value '{prop.Value}' to type {info.PropertyType.Name}: {ex.Message}");
+ continue;
+ }
}
totalPopulated++;
}
@@ -453,7 +526,12 @@ internal static string GetExeName()
{
try
{
- return System.IO.Path.GetFileNameWithoutExtension(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);
+ string fileName = System.IO.Path.GetFileName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);
+ if (fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ fileName = fileName.Substring(0, fileName.Length - 4);
+ }
+ return fileName;
}
catch (Exception ex)
{
@@ -470,7 +548,14 @@ public static PluginActionId[] AutoLoadPluginActions()
{
List actions = new List();
- var pluginTypes = Assembly.GetEntryAssembly().GetTypes().Where(typ => typ.IsClass && typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).Length > 0).ToList();
+ var entryAssembly = Assembly.GetEntryAssembly();
+ if (entryAssembly == null)
+ {
+ Logger.Instance.LogMessage(TracingLevel.ERROR, "AutoLoadPluginActions: Assembly.GetEntryAssembly() returned null");
+ return actions.ToArray();
+ }
+
+ var pluginTypes = entryAssembly.GetTypes().Where(typ => typ.IsClass && typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).Length > 0).ToList();
pluginTypes.ForEach(typ =>
{
if (typ.GetCustomAttributes(typeof(PluginActionIdAttribute), true).First() is PluginActionIdAttribute attr)
diff --git a/barraider-sdtools/Wrappers/TitleParameters.cs b/barraider-sdtools/Wrappers/TitleParameters.cs
index 1c893b1..77a5e5a 100644
--- a/barraider-sdtools/Wrappers/TitleParameters.cs
+++ b/barraider-sdtools/Wrappers/TitleParameters.cs
@@ -1,7 +1,9 @@
-using Newtonsoft.Json;
+using Newtonsoft.Json;
+using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Drawing;
+using System.Runtime.InteropServices;
using System.Text;
namespace BarRaider.SdTools.Wrappers
@@ -63,10 +65,38 @@ public class TitleParameters
public double FontSizeInPixelsScaledToDefaultImage => Math.Round(FontSizeInPixels * DEFAULT_IMAGE_SIZE_FONT_SCALE);
///
- /// Font Family
+ /// Font family name as a plain string. Cross-platform replacement for FontFamily.
///
- [JsonProperty("fontFamily")]
- public FontFamily FontFamily { get; private set; } = new FontFamily(DEFAULT_FONT_FAMILY_NAME);
+ [JsonIgnore]
+ public string FontFamilyName { get; private set; } = DEFAULT_FONT_FAMILY_NAME;
+
+ private FontFamily cachedFontFamily;
+
+ ///
+ /// Font Family. Windows-only; throws PlatformNotSupportedException on macOS/Linux.
+ /// Use or for cross-platform code.
+ ///
+ [Obsolete("Use FontFamilyName (string) or TitleTypeface (SKTypeface) for cross-platform code.")]
+ [JsonIgnore]
+ public FontFamily FontFamily
+ {
+ get
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ throw new PlatformNotSupportedException(
+ "FontFamily requires GDI+ which is not available on this platform. " +
+ "Use FontFamilyName (string) or TitleTypeface (SKTypeface) instead.");
+
+ if (cachedFontFamily == null)
+ cachedFontFamily = new FontFamily(FontFamilyName);
+ return cachedFontFamily;
+ }
+ private set
+ {
+ cachedFontFamily = value;
+ FontFamilyName = value?.Name ?? DEFAULT_FONT_FAMILY_NAME;
+ }
+ }
///
/// Font Style
@@ -86,6 +116,50 @@ public class TitleParameters
[JsonProperty("titleAlignment")]
public TitleVerticalAlignment VerticalAlignment { get; private set; }
+ ///
+ /// Title color as an SKColor for cross-platform SkiaSharp rendering.
+ /// Computed from the existing TitleColor property.
+ ///
+ [JsonIgnore]
+ public SKColor TitleSKColor => new SKColor(TitleColor.R, TitleColor.G, TitleColor.B, TitleColor.A);
+
+ [JsonIgnore]
+ private SKTypeface cachedTypeface;
+
+ ///
+ /// Font as an SKTypeface for cross-platform SkiaSharp rendering.
+ /// Computed and cached from .
+ /// The returned typeface is owned by this TitleParameters instance.
+ ///
+ [JsonIgnore]
+ public SKTypeface TitleTypeface
+ {
+ get
+ {
+ if (cachedTypeface == null)
+ {
+ string familyName = FontFamilyName ?? DEFAULT_FONT_FAMILY_NAME;
+ cachedTypeface = SKTypeface.FromFamilyName(familyName, FontStyleToSKFontStyle());
+ }
+ return cachedTypeface;
+ }
+ }
+
+ ///
+ /// Converts the System.Drawing FontStyle to an equivalent SKFontStyle.
+ ///
+ /// The corresponding SKFontStyle.
+ public SKFontStyle FontStyleToSKFontStyle()
+ {
+ bool bold = (FontStyle & System.Drawing.FontStyle.Bold) != 0;
+ bool italic = (FontStyle & System.Drawing.FontStyle.Italic) != 0;
+
+ var weight = bold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal;
+ var slant = italic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright;
+
+ return new SKFontStyle(weight, SKFontStyleWidth.Normal, slant);
+ }
+
///
/// Constructor
///
@@ -127,15 +201,19 @@ private void ParsePayload(string fontFamily, uint fontSize, string fontStyle, bo
{
ShowTitle = showTitle;
- // Color
if (!String.IsNullOrEmpty(titleColor))
{
- TitleColor = ColorTranslator.FromHtml(titleColor);
+ if (SKColor.TryParse(titleColor, out SKColor skColor))
+ {
+ TitleColor = Color.FromArgb(skColor.Alpha, skColor.Red, skColor.Green, skColor.Blue);
+ }
}
if (!String.IsNullOrEmpty(fontFamily))
{
- FontFamily = new FontFamily(fontFamily);
+ FontFamilyName = fontFamily;
+ cachedFontFamily = null;
+ cachedTypeface = null;
}
FontSizeInPoints = fontSize;
diff --git a/barraider-sdtools/barraider-sdtools.csproj b/barraider-sdtools/barraider-sdtools.csproj
index 1aabfc5..039790a 100644
--- a/barraider-sdtools/barraider-sdtools.csproj
+++ b/barraider-sdtools/barraider-sdtools.csproj
@@ -1,6 +1,6 @@
-
+
- netstandard2.0;net8.0
+ netstandard2.0;net48;net8.0;net10.0
true
BarRaider
Stream Deck Tools by BarRaider
@@ -15,41 +15,27 @@ Feel free to contact me for more information: https://barraider.comStreamDeck Elgato Library Plugin Stream Deck Toolkit
StreamDeck-Tools
- 6.4
- 6.4
- 6.4
- 6.4 - Support for newest Stream Deck devices
-NOTE: Final version with .netstandard support. Moving to .NET 8/9 in next versions!
+ 7.0.0.0
+ 7.0.0.0
+ 7.0.0
+ 7.0.0 - Cross-platform SkiaSharp API surface (SkiaTools, SkiaGraphicsTools, SkiaExtensionMethods). Target frameworks: netstandard2.0, net48, net8.0, net10.0. All System.Drawing APIs marked [Obsolete] with migration guidance. New SetImageAsync(SKBitmap) and SetImageAsync(byte[]) overloads. DrawTextLine extension for SKCanvas. TitleParameters cross-platform properties (FontFamilyName, TitleSKColor, TitleTypeface). See MIGRATION.md for the full migration guide.
BarRaider.SdTools
StreamDeckTools
BRLogo_460.png
README.md
LICENSE
-
-
- 1701;1702;CA1416
-
-
-
-
-
- streamdeck-tools.xml
- 1701;1702;CA1416
-
-
- streamdeck-tools.xml
-
-
- 1701;1702;CA1416
-
-
+
1701;1702;CA1416
+ streamdeck-tools.xml
+
+
+
@@ -64,7 +50,7 @@ NOTE: Final version with .netstandard support. Moving to .NET 8/9 in next versio
True
-
+
PreserveNewest
diff --git a/barraider-sdtools/streamdeck-tools.xml b/barraider-sdtools/streamdeck-tools.xml
index 006aa82..bc554b9 100644
--- a/barraider-sdtools/streamdeck-tools.xml
+++ b/barraider-sdtools/streamdeck-tools.xml
@@ -271,6 +271,26 @@
Should image be sent even if it is identical to the one sent previously. Default is false
+
+
+ Sets an image on the StreamDeck key from raw PNG bytes.
+ Prefer this over the Image overload to avoid System.Drawing dependencies.
+
+ PNG-encoded image bytes
+ A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ Should image be sent even if it is identical to the one sent previously. Default is false
+
+
+
+
+ Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp).
+ The bitmap is PNG-encoded and sent as base64.
+
+ SkiaSharp bitmap to display on the key
+ A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ Should image be sent even if it is identical to the one sent previously. Default is false
+
+
Sets the default image for this state, as configured in the manifest
@@ -764,6 +784,26 @@
Should image be sent even if it is identical to the one sent previously. Default is false
+
+
+ Sets an image on the StreamDeck key from raw PNG bytes.
+ Prefer this over the Image overload to avoid System.Drawing dependencies.
+
+ PNG-encoded image bytes
+ A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ Should image be sent even if it is identical to the one sent previously. Default is false
+
+
+
+
+ Sets an image on the StreamDeck key from an SKBitmap (cross-platform, SkiaSharp).
+ The bitmap is PNG-encoded and sent as base64.
+
+ SkiaSharp bitmap to display on the key
+ A 0-based integer value representing the state of an action with multiple states. This is an optional parameter. If not specified, the title is set to all states.
+ Should image be sent even if it is identical to the one sent previously. Default is false
+
+
Sets the default image for this state, as configured in the manifest
@@ -1625,6 +1665,19 @@
+
+
+ Internal image codec abstraction used by compatibility adapters.
+
+
+
+
+ Compatibility image codec implementation backed by System.Drawing.
+ Image.FromStream requires the backing stream to remain open for the
+ lifetime of the Image, so DecodeFromBytes copies pixel data into a
+ new Bitmap and disposes the intermediate resources.
+
+
Payload for Apperance settings
@@ -2270,7 +2323,14 @@
- Converts an Image into a Byte Array
+ Converts an Image into a BMP Byte Array.
+
+
+
+
+
+
+ Converts an Image into a PNG Byte Array using the internal codec abstraction.
@@ -2641,6 +2701,288 @@
+
+
+ SkiaSharp extension methods for SKBitmap and SKCanvas.
+ These are the cross-platform equivalents of the System.Drawing extension methods in .
+
+
+
+
+ Converts an SKBitmap to a PNG byte array.
+
+ The bitmap to encode.
+ PNG-encoded byte array.
+
+
+
+ Converts an SKBitmap to a Base64 PNG string.
+
+ The bitmap to encode.
+ Whether to prepend the data URI header.
+ Base64-encoded PNG string.
+
+
+
+ Draws a line of text treating the Y coordinate as the top of the text box
+ (matching System.Drawing.Graphics.DrawString behavior) and returns the Y
+ position for the next line, based on the font's recommended line spacing.
+
+ The canvas to draw on.
+ The text to draw.
+ The font for text rendering.
+ The paint for color/style.
+ The position to draw at (X = left edge, Y = top of text).
+ The Y coordinate for the top of the next line.
+
+
+
+ Draws a string on an SKCanvas and returns the Y position for the next line.
+ Equivalent to . Position Y is the top of the text, not the baseline.
+
+ The canvas to draw on.
+ The text to draw.
+ The font for text rendering.
+ The paint for color/style.
+ The position to draw at (X = left edge, Y = top of text).
+ The Y coordinate for the top of the next line.
+
+
+
+ Returns the center X position of a string given the image width and font.
+
+ The canvas (used for consistency with the System.Drawing extension signature).
+ The text to measure.
+ The total image width.
+ The font for measurement.
+ Set to true if the text fits within the image width.
+ Minimum indentation when text overflows.
+ The X coordinate to center the text.
+
+
+
+ Returns the center X position of a string given the image width and font.
+
+ The canvas.
+ The text to measure.
+ The total image width.
+ The font for measurement.
+ Minimum indentation when text overflows.
+ The X coordinate to center the text.
+
+
+
+ Returns the largest font size at which the text fits within the given image width.
+
+ The canvas.
+ The text to measure.
+ Maximum width in pixels.
+ The base font (used for typeface and initial size).
+ The smallest acceptable font size.
+ The computed font size in the same units as font.Size.
+
+
+
+ Adds styled text to an SKCanvas using TitleParameters for layout.
+ Emulates the text rendering settings from the Stream Deck Property Inspector.
+
+ The canvas to draw on.
+ Title parameters from the Stream Deck event.
+ Height of the key image.
+ Width of the key image.
+ The text to draw.
+ Alignment offset in pixels.
+
+
+
+ Adds styled text to an SKCanvas using TitleParameters for layout, with stroke settings.
+
+ The canvas to draw on.
+ Title parameters from the Stream Deck event.
+ Height of the key image.
+ Width of the key image.
+ The text to draw.
+ Color for the text stroke.
+ Thickness of the text stroke.
+ Alignment offset in pixels.
+
+
+
+ Adds line breaks to a string so it fits within the key image when using SetTitleAsync().
+ SkiaSharp equivalent of .
+
+ The string to split.
+ Title parameters for font information.
+ The SKFont used for text measurement.
+ Left padding in pixels.
+ Right padding in pixels.
+ Width of the key image in pixels.
+ The string with line breaks inserted.
+
+
+
+ Cross-platform graphics manipulation utilities using SkiaSharp.
+ These methods are the SkiaSharp equivalents of the System.Drawing methods in .
+
+
+
+
+ Parse a hex color string into an SKColor.
+
+ Hex string such as "#FF0000" or "#80FF0000".
+ The parsed SKColor.
+
+
+
+ Generates multiple shades based on an initial color and the number of stages.
+
+ Initial hex color string.
+ The current shade index (0-based).
+ Total number of shade levels.
+ A darker shade of the initial color.
+
+
+
+ Resizes an image while maintaining aspect ratio and centering on a black background.
+
+ The source bitmap.
+ Target width.
+ Target height.
+ A new resized SKBitmap. Caller must dispose. Returns null if original is null.
+
+
+
+ Extracts a rectangular region from an image.
+
+ The source bitmap.
+ Left edge of the rectangle.
+ Top edge of the rectangle.
+ Width of the rectangle.
+ Height of the rectangle.
+ A new SKBitmap with the extracted region. Caller must dispose.
+
+
+
+ Creates a copy of the image with modified opacity.
+
+ The source bitmap.
+ Opacity value from 0.0 (transparent) to 1.0 (opaque).
+ A new SKBitmap with the applied opacity. Caller must dispose.
+
+
+
+ Draws multi-lined text onto key images. Generates one or more images where each has
+ text drawn based on the given parameters.
+
+ The text to draw.
+ Starting character position.
+ Maximum characters per line.
+ Maximum lines per image.
+ The SKFont for text rendering.
+ Background fill color.
+ Text color.
+ If true, overflow text creates additional images.
+ Starting draw position on the key.
+ Array of SKBitmaps with drawn text. Caller must dispose each bitmap.
+
+
+
+ Adds line breaks to a string so that it fits within the given image width
+ when rendered with the specified font.
+
+ The string to wrap.
+ The SKFont used for measurement.
+ The maximum width in pixels.
+ Left padding in pixels.
+ Right padding in pixels.
+ The string with line breaks inserted.
+
+
+
+ Cross-platform image utilities using SkiaSharp. These methods are the SkiaSharp equivalents
+ of the System.Drawing methods in and work on Windows, macOS, and Linux.
+
+
+
+
+ Generates an empty key bitmap with the default dimensions for the given Stream Deck device type.
+ The returned SKCanvas is ready for drawing and must be disposed by the caller.
+
+ The Stream Deck device type.
+ The SKCanvas for drawing on the bitmap. Caller must dispose.
+ An SKBitmap sized for the device. Caller must dispose.
+
+
+
+ Creates a key image that fits all Stream Deck models (144x144).
+ The returned SKCanvas is ready for drawing and must be disposed by the caller.
+
+ The SKCanvas for drawing on the bitmap. Caller must dispose.
+ An SKBitmap. Caller must dispose.
+
+
+
+ Convert an SKBitmap to a Base64 PNG string.
+ Set addHeaderPrefix to true if sending to SetImageAsync.
+
+ The bitmap to encode.
+ Whether to prepend the data URI header.
+ Base64-encoded PNG string, or null if bitmap is null.
+
+
+
+ Convert a base64 image string to an SKBitmap.
+
+ Base64-encoded image, optionally with a data URI header.
+ An SKBitmap, or null on failure. Caller must dispose.
+
+
+
+ Convert an image file to a Base64 PNG string.
+ Set addHeaderPrefix to true if sending to SetImageAsync.
+
+ Path to the image file.
+ Whether to prepend the data URI header.
+ Base64-encoded PNG string, or null if file doesn't exist.
+
+
+
+ Loads an image from a file path. Returns an independent SKBitmap that does not lock the file.
+
+ Path to the image file.
+ An SKBitmap, or null if the path is invalid or file doesn't exist. Caller must dispose.
+
+
+
+ Loads an image from a stream. The caller may close the stream after this returns.
+
+ The stream containing image data.
+ An SKBitmap, or null if the stream is null. Caller must dispose.
+
+
+
+ Returns a SHA512 hash of the PNG-encoded bitmap.
+
+ The bitmap to hash.
+ Hex-encoded SHA512 hash, or null on failure.
+
+
+
+ Creates an SKFont from a font family name, size in points, and optional style.
+ The caller is responsible for disposing the returned SKFont.
+
+ Font family name (e.g. "Arial", "Verdana").
+ Font size in points.
+ SkiaSharp font style. Defaults to Normal.
+ A new SKFont. Caller must dispose.
+
+
+
+ Parses a hex color string (e.g. "#FF0000" or "#AAFF0000") into an SKColor.
+
+ Hex color string with leading '#'.
+ The parsed SKColor.
+
Set of common utilities used by various plugins
@@ -2655,6 +2997,22 @@
+
+
+ Loads an image from a file path. Returns an independent copy that does not lock the file.
+ Replaces direct usage of Image.FromFile which holds a file lock.
+
+
+ An Image, or null if the path is null/empty or the file does not exist.
+
+
+
+ Loads an image from a stream. Returns an independent copy; the caller may close the stream after this returns.
+ Replaces direct usage of Image.FromStream which requires the stream to remain open.
+
+
+ An Image, or null if the stream is null.
+
Convert a in-memory image object to Base64 format. Set the addHeaderPrefix to true, if this is sent to the SendImageAsync function
@@ -2695,6 +3053,18 @@
+
+
+ Creates a Font from a family name, size in points, and optional style.
+ Prefer this over calling new Font(...) directly, as this helper will be
+ adapted to alternative backends in a future release.
+ The caller is responsible for disposing the returned Font.
+
+ Font family name (e.g. "Arial", "Verdana").
+ Font size in points.
+ Font style flags. Defaults to Regular.
+ A new Font instance. The caller must dispose it when done.
+
Creates a key image that fits all Stream Decks
@@ -2869,9 +3239,15 @@
Font Size Scaled to Image
+
+
+ Font family name as a plain string. Cross-platform replacement for FontFamily.
+
+
- Font Family
+ Font Family. Windows-only; throws PlatformNotSupportedException on macOS/Linux.
+ Use or for cross-platform code.
@@ -2889,6 +3265,25 @@
Alignment position of the Title text on the key
+
+
+ Title color as an SKColor for cross-platform SkiaSharp rendering.
+ Computed from the existing TitleColor property.
+
+
+
+
+ Font as an SKTypeface for cross-platform SkiaSharp rendering.
+ Computed and cached from .
+ The returned typeface is owned by this TitleParameters instance.
+
+
+
+
+ Converts the System.Drawing FontStyle to an equivalent SKFontStyle.
+
+ The corresponding SKFontStyle.
+
Constructor