From c04b24f72283cf3cbaa8047a8a6e077bbaf0ea6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 05:46:55 +0000 Subject: [PATCH] Add shared Liberate icon generator to LibationUiBase Both UIs need the same stoplight/PDF/error/series icons. WinForms selected pre-baked PNGs by name while Avalonia composed vector layers in XAML, so the two drifted and every new combination needed another pair of PNGs. Port the Avalonia artwork and palette into LibationUiBase and rasterize it with SkiaSharp, keyed by a descriptor of the finite combinations and cached in memory. EntryStatus.ButtonImage now serves that shared rendering to both UIs. Co-authored-by: rmcrackan --- Source/LibationUiBase/BaseUtil.cs | 17 +- Source/LibationUiBase/GridView/EntryStatus.cs | 49 +++-- Source/LibationUiBase/LibationUiBase.csproj | 6 + .../StatusIcons/LiberateIconDescriptor.cs | 59 +++++++ .../StatusIcons/LiberateIconGeometry.cs | 73 ++++++++ .../StatusIcons/LiberateIconPalette.cs | 45 +++++ .../StatusIcons/StatusImageGenerator.cs | 167 ++++++++++++++++++ 7 files changed, 387 insertions(+), 29 deletions(-) create mode 100644 Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs create mode 100644 Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs create mode 100644 Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs create mode 100644 Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs diff --git a/Source/LibationUiBase/BaseUtil.cs b/Source/LibationUiBase/BaseUtil.cs index 27d50922..fbe87ce6 100644 --- a/Source/LibationUiBase/BaseUtil.cs +++ b/Source/LibationUiBase/BaseUtil.cs @@ -8,16 +8,16 @@ public static class BaseUtil /// A delegate that loads image bytes into the the UI framework's image format. public static Func LoadImage => s_LoadImage ?? DefaultLoadImageImpl; - /// A delegate that loads a named resource into the the UI framework's image format. - public static Func LoadResourceImage => s_LoadResourceImage ?? DefaultLoadResourceImageImpl; + /// A delegate that reports whether the UI framework is currently using a dark theme. + public static Func IsDarkMode => s_IsDarkMode ?? DefaultIsDarkModeImpl; public static void SetLoadImageDelegate(Func tryLoadImage) => s_LoadImage = tryLoadImage; - public static void SetLoadResourceImageDelegate(Func tryLoadResourceImage) - => s_LoadResourceImage = tryLoadResourceImage; + public static void SetIsDarkModeDelegate(Func isDarkMode) + => s_IsDarkMode = isDarkMode; private static Func? s_LoadImage; - private static Func? s_LoadResourceImage; + private static Func? s_IsDarkMode; private static object? DefaultLoadImageImpl(byte[]? imageBytes, PictureSize size) { @@ -25,9 +25,6 @@ public static class BaseUtil return null; } - private static object? DefaultLoadResourceImageImpl(string resourceName) - { - Serilog.Log.Error("{LoadResourceImage} called without a delegate set. Resource name: {ResourceName}", nameof(LoadResourceImage), resourceName); - return null; - } + /// Light is the safe assumption for hosts without a theme, like the CLI and tests. + private static bool DefaultIsDarkModeImpl() => false; } diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index af0c5321..34c56775 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -1,6 +1,8 @@ using ApplicationServices; using DataLayer; using Dinah.Core; +using LibationFileManager; +using LibationUiBase.StatusIcons; using System; using System.Collections.Generic; @@ -53,14 +55,14 @@ public class EntryStatus : ReactiveObject, IComparable || PdfStatus is not null and not LiberatedStatus.Liberated ); public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1; - public object? ButtonImage => GetLiberateIcon(); + public object? ButtonImage => GetAndCacheIcon(GetLiberateIconDescriptor()); public string ToolTip => GetTooltip(); private Book Book { get; } private DateTime lastBookUpdate; private LiberatedStatus bookStatus; private readonly bool isAbsent; - private static readonly Dictionary iconCache = new(); + private static readonly Dictionary iconCache = []; internal EntryStatus(LibraryBook libraryBook) { @@ -99,32 +101,34 @@ public class EntryStatus : ReactiveObject, IComparable else return PdfStatus.Value.CompareTo(second.PdfStatus.Value); } - private object? GetLiberateIcon() + private LiberateIconDescriptor GetLiberateIconDescriptor() { + var isDark = BaseUtil.IsDarkMode(); + if (IsSeries) - return Expanded ? GetAndCacheResource("minus") : GetAndCacheResource("plus"); + return LiberateIconDescriptor.ForSeries(Expanded, isDark); if (BookStatus == LiberatedStatus.Error) - return GetAndCacheResource("error"); + return LiberateIconDescriptor.ForError(isDark); - string image_lib = BookStatus switch + var lamp = BookStatus switch { - LiberatedStatus.Liberated => "green", - LiberatedStatus.PartialDownload => "yellow", - LiberatedStatus.NotLiberated => "red", + LiberatedStatus.Liberated => StoplightLamp.Green, + LiberatedStatus.PartialDownload => StoplightLamp.Yellow, + LiberatedStatus.NotLiberated => StoplightLamp.Red, _ => throw new Exception("Unexpected liberation state") }; - string image_pdf = PdfStatus switch + var pdf = PdfStatus switch { - LiberatedStatus.Liberated => "_pdf_yes", - LiberatedStatus.NotLiberated => "_pdf_no", - LiberatedStatus.Error => "_pdf_no", - null => "", + LiberatedStatus.Liberated => PdfOverlay.Downloaded, + LiberatedStatus.NotLiberated => PdfOverlay.NotDownloaded, + LiberatedStatus.Error => PdfOverlay.NotDownloaded, + null => PdfOverlay.None, _ => throw new Exception("Unexpected PDF state") }; - return GetAndCacheResource($"liberate_{image_lib}{image_pdf}"); + return LiberateIconDescriptor.ForBook(lamp, pdf, isDark); } private string GetTooltip() @@ -165,10 +169,17 @@ public class EntryStatus : ReactiveObject, IComparable return mouseoverText; } - private object? GetAndCacheResource(string rescName) + /// + /// Load the shared rendering of an icon into this UI framework's image format. There are only a + /// couple dozen icons, and grid entries are created on several threads, so cache them all. + /// + private static object? GetAndCacheIcon(LiberateIconDescriptor descriptor) { - if (!iconCache.ContainsKey(rescName)) - iconCache[rescName] = BaseUtil.LoadResourceImage(rescName); - return iconCache[rescName]; + lock (iconCache) + { + if (!iconCache.TryGetValue(descriptor, out var icon)) + iconCache[descriptor] = icon = BaseUtil.LoadImage(StatusImageGenerator.GetPng(descriptor), PictureSize.Native); + return icon; + } } } diff --git a/Source/LibationUiBase/LibationUiBase.csproj b/Source/LibationUiBase/LibationUiBase.csproj index d97f8424..4f9b69ab 100644 --- a/Source/LibationUiBase/LibationUiBase.csproj +++ b/Source/LibationUiBase/LibationUiBase.csproj @@ -9,6 +9,12 @@ false + + + + + diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs b/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs new file mode 100644 index 00000000..f43e812d --- /dev/null +++ b/Source/LibationUiBase/StatusIcons/LiberateIconDescriptor.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace LibationUiBase.StatusIcons; + +/// What the Liberate column's icon depicts. +public enum LiberateIconKind +{ + /// A stoplight, optionally with a PDF glyph beside it. + Book, + /// A series row which can be expanded. + SeriesCollapsed, + /// A series row which can be collapsed. + SeriesExpanded, + /// A book whose download errored. + Error +} + +/// Which of the stoplight's three lamps is lit. +public enum StoplightLamp { Red, Yellow, Green } + +/// The PDF glyph drawn beside the stoplight, if any. +public enum PdfOverlay { None, Downloaded, NotDownloaded } + +/// +/// Identifies one Liberate column icon. There is a small, finite number of these, so +/// caches a rendering of each one. +/// +/// +/// Use the factory methods rather than the constructor: they leave the members which don't apply to +/// a given at their default, so that (for example) every series row +/// shares one cache entry instead of one per lamp/PDF combination. +/// +public readonly record struct LiberateIconDescriptor(LiberateIconKind Kind, StoplightLamp Lamp, PdfOverlay Pdf, bool IsDark) +{ + public static LiberateIconDescriptor ForBook(StoplightLamp lamp, PdfOverlay pdf, bool isDark) + => new(LiberateIconKind.Book, lamp, pdf, isDark); + + public static LiberateIconDescriptor ForSeries(bool expanded, bool isDark) + => new(expanded ? LiberateIconKind.SeriesExpanded : LiberateIconKind.SeriesCollapsed, default, default, isDark); + + public static LiberateIconDescriptor ForError(bool isDark) + => new(LiberateIconKind.Error, default, default, isDark); + + /// Every icon the Liberate column can display. + public static IEnumerable All() + { + foreach (var isDark in new[] { false, true }) + { + foreach (var lamp in Enum.GetValues()) + foreach (var pdf in Enum.GetValues()) + yield return ForBook(lamp, pdf, isDark); + + yield return ForSeries(expanded: false, isDark); + yield return ForSeries(expanded: true, isDark); + yield return ForError(isDark); + } + } +} diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs b/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs new file mode 100644 index 00000000..060b79e5 --- /dev/null +++ b/Source/LibationUiBase/StatusIcons/LiberateIconGeometry.cs @@ -0,0 +1,73 @@ +namespace LibationUiBase.StatusIcons; + +/// +/// The vector artwork and palette for the Liberate column's icons. These are the single source of +/// truth for both UIs; rasterizes them. +/// +/// +/// Path data is SVG path mini-language, and is filled using the even-odd rule so that nested +/// subpaths (the stoplight's lamp bezels, the error ring, the series square's border) cut holes. +/// +internal static class LiberateIconGeometry +{ + /// Rounded body with three lamp bezels cut out of it. Natural size 46 x 100. + public const string StoplightBody = """ + M0,12 A 12,12 0 0 1 12,0 H34 A 12,12 0 0 1 46,12 V88 A 12,12 0 0 1 34,100 H12 A 12,12 0 0 1 0,88 V12 + M20,8 H26 A 12,12 0 0 1 26,32 H20 A 12,12 0 0 1 20,8 + M20,38 H26 A 12,12 0 0 1 26,62 H20 A 12,12 0 0 1 20,38 + M20,68 H26 A 12,12 0 0 1 26,92 H20 A 12,12 0 0 1 20,68 + """; + + /// A PDF document glyph. Natural size 45 x 50.5. + public const string PdfDocument = """ + M4,38.5 H3 A 3,3 0 0 1 0,35.5 V21.4 A 3,3 0 0 1 3,18.4 H4 V2 A 2,2 0 0 1 6,0 H30.5 L41,12 V18.4 A 3,3 0 0 1 45,21.4 V35.5 A 3,3 0 0 1 42,38.5 H41 V48.5 A 2,2 0 0 1 39,50.5 H6 A 2,2 0 0 1 4,48.5 + M6,38.5 H39 V48.5 H6 V38.5 + M6,18.4 V2 H29 V12 A 1,1 0 0 0 30,13 H39 V18.4 + M 4.3179,36 c 0,0 0.122,-14.969 0.122,-14.969 1.469,-0.194 2.939,-0.388 4.5,-0.362 1.561,0.026 3.214,0.27 4.357,0.944 1.143,0.674 1.775,1.776 2.015,2.959 0.24,1.184 0.087,2.449 -0.5,3.52 -0.587,1.071 -1.607,1.949 -2.816,2.352 -1.209,0.403 -2.607,0.332 -4.005,0.26 0,0 -0.031,5.265 -0.031,5.265 0,0 -3.673,0.122 -3.673,0.122 0,0 0.031,-0.092 0.031,-0.092 + m 3.643,-12.428 c 0,0 0.031,4.286 0.031,4.286 0.735,0.051 1.47,0.102 2.107,-0.056 0.638,-0.158 1.178,-0.526 1.459,-1.122 0.281,-0.597 0.301,-1.423 0.01,-2.005 -0.291,-0.582 -0.893,-0.918 -1.546,-1.061 -0.653,-0.143 -1.357,-0.092 -1.709,-0.066 -0.352,0.026 -0.352,0.026 -0.352,0.026 + m 9.428,12.428 c 2.265,0.245 4.531,0.49 6.674,0.066 2.143,-0.424 4.163,-1.515 5.285,-3.081 1.122,-1.566 1.347,-3.607 1.27,-5.306 -0.076,-1.699 -0.454,-3.056 -1.454,-4.219 -1,-1.163 -2.622,-2.133 -4.704,-2.505 -2.082,-0.373 -4.623,-0.148 -7.164,0.076 0,0 0.092,14.969 0.092,14.969 + m 3.49,-12.398 c 0,0 0,9.673 0,9.673 0.888,0.02 1.776,0.041 2.653,-0.179 0.877,-0.219 1.745,-0.679 2.367,-1.541 0.622,-0.862 1,-2.127 0.98,-3.403 -0.02,-1.275 -0.439,-2.561 -1.193,-3.337 -0.755,-0.776 -1.847,-1.041 -2.704,-1.158 -0.857,-0.117 -1.48,-0.087 -2.102,-0.056 + m 11.908,12.245 v-14.785 h8.969 v2.51 h-5.786 v3.612 h5.388 v2.51 h-5.449 v6.092 + """; + + /// A download arrow, unioned with to mean "PDF not downloaded". + public const string PdfDownArrow = """ + M29,44 V58.7498 H35.0491 A 1.5,1.5 0 0 1 36.1342,61.2861 L23.5607,73.8595 A 1.5,1.5 0 0 1 21.4393,73.8595 L8.8658,61.2861 A 1.5,1.5 0 0 1 9.9509,58.7498 H16 V44 A 1.5,1.5 0 0 1 17.5,42.5 H27.5 A 1.5,1.5 0 0 1 29,44 + """; + + /// A rounded square containing a minus bar. Natural size 64 x 64. + public const string SeriesCollapse = """ + M0,2 A 2,2 0 0 1 2,0 H62 A2,2 0 0 1 64,2 V62 A 2,2 0 0 1 62,64 H 2 A 2,2 0 0 1 0,62 V2 + M 2,2 H62 V62 H2 V2 + M11,28 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1 + """; + + /// A vertical bar, unioned with to turn the minus into a plus. + public const string SeriesExpandBar + = "M28,53 v-42 a 1,1 0 0 1 1,-1 h6 a 1,1 0 0 1 1,1 v42 a 1,1 0 0 1 -1,1 h-6 a 1,1 0 0 1 -1,-1"; + + /// A "no entry" sign. Natural size 64 x 64. + public const string BookError + = "M32,0 a 32,32 0 0 1 0,64 a 32,32 0 0 1 0,-64 m 0,4 a 28,28 0 0 1 0,56 a 28,28 0 0 1 0,-56 m-21,24 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1"; + + #region Layout, in the same units as the artwork above + + /// The height the stoplight body is scaled to. Everything else is sized relative to it. + public const float StoplightHeight = 64; + + /// Lamp size, in stoplight-body-scaled units. + public const float LampWidth = 20; + public const float LampHeight = 18; + + /// Lamp offsets from the top-left of the scaled stoplight body. + public const float LampLeft = 5; + public const float RedLampTop = 5; + public const float YellowLampTop = 23; + public const float GreenLampTop = 42; + + /// The width the PDF glyph is scaled to, and the gap between it and the stoplight. + public const float PdfWidth = 28.8f; + public const float PdfLeftMargin = 4; + + #endregion +} diff --git a/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs b/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs new file mode 100644 index 00000000..b89aca87 --- /dev/null +++ b/Source/LibationUiBase/StatusIcons/LiberateIconPalette.cs @@ -0,0 +1,45 @@ +using SkiaSharp; + +namespace LibationUiBase.StatusIcons; + +/// +/// The Liberate icon colors for one theme variant. These mirror the brushes both UIs' themes +/// declare, so the generated icons match the surrounding chrome. +/// +internal readonly struct LiberateIconPalette +{ + public required SKColor IconFill { get; init; } + public required SKColor Red { get; init; } + public required SKColor Yellow { get; init; } + public required SKColor Green { get; init; } + public required SKColor Error { get; init; } + + public SKColor Lamp(StoplightLamp lamp) => lamp switch + { + StoplightLamp.Red => Red, + StoplightLamp.Yellow => Yellow, + StoplightLamp.Green => Green, + _ => IconFill + }; + + public static LiberateIconPalette For(bool isDark) => isDark ? Dark : Light; + + private static readonly LiberateIconPalette Light = new() + { + IconFill = new SKColor(0x23, 0x1F, 0x20), + Red = new SKColor(0xF0, 0x60, 0x60), + Yellow = new SKColor(0xF0, 0xE1, 0x60), + Green = new SKColor(0x70, 0xFA, 0x70), + //FireBrick + Error = new SKColor(0xB2, 0x22, 0x22) + }; + + private static readonly LiberateIconPalette Dark = new() + { + IconFill = new SKColor(0xDC, 0xE0, 0xDF), + Red = new SKColor(0x7D, 0x1F, 0x1F), + Yellow = new SKColor(0x7D, 0x7D, 0x1F), + Green = new SKColor(0x1F, 0x7D, 0x1F), + Error = new SKColor(0x80, 0x27, 0x27) + }; +} diff --git a/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs b/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs new file mode 100644 index 00000000..cd532bf3 --- /dev/null +++ b/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs @@ -0,0 +1,167 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; + +namespace LibationUiBase.StatusIcons; + +/// +/// Rasterizes the Liberate column's icons from so that every UI +/// displays the same image. Renderings are cached; there are only a couple dozen of them. +/// +public static class StatusImageGenerator +{ + /// + /// Icons are rendered this many pixels per unit of artwork so they stay sharp on scaled displays. + /// Consumers which size an image from its pixel dimensions must divide by this. + /// + public const int RenderScale = 2; + + /// Render 's icon to a PNG. + public static byte[] GetPng(LiberateIconDescriptor descriptor) + { + lock (cache) + { + if (!cache.TryGetValue(descriptor, out var png)) + cache[descriptor] = png = Render(descriptor); + return png; + } + } + + private static readonly Dictionary cache = []; + + private static byte[] Render(LiberateIconDescriptor descriptor) + { + var (layers, size) = Compose(descriptor, LiberateIconPalette.For(descriptor.IsDark)); + + var info = new SKImageInfo( + (int)MathF.Ceiling(size.Width * RenderScale), + (int)MathF.Ceiling(size.Height * RenderScale), + SKColorType.Rgba8888, + SKAlphaType.Premul); + + using var surface = SKSurface.Create(info); + surface.Canvas.Clear(SKColors.Transparent); + surface.Canvas.Scale(RenderScale); + + using var paint = new SKPaint { IsAntialias = true, Style = SKPaintStyle.Fill }; + foreach (var (path, color) in layers) + { + paint.Color = color; + surface.Canvas.DrawPath(path, paint); + path.Dispose(); + } + + using var image = surface.Snapshot(); + using var png = image.Encode(SKEncodedImageFormat.Png, 100); + return png.ToArray(); + } + + /// Lay the icon's paths out, bottom layer first, in artwork units. + private static (List<(SKPath Path, SKColor Color)> Layers, SKSize Size) Compose(LiberateIconDescriptor descriptor, LiberateIconPalette palette) + { + var layers = new List<(SKPath, SKColor)>(); + + switch (descriptor.Kind) + { + case LiberateIconKind.Error: + { + var error = ParsePath(LiberateIconGeometry.BookError); + var size = MoveToOrigin(error); + layers.Add((error, palette.Error)); + return (layers, size); + } + + case LiberateIconKind.SeriesCollapsed: + case LiberateIconKind.SeriesExpanded: + { + var series = ParsePath(LiberateIconGeometry.SeriesCollapse); + if (descriptor.Kind is LiberateIconKind.SeriesExpanded) + series = Union(series, ParsePath(LiberateIconGeometry.SeriesExpandBar)); + var size = MoveToOrigin(series); + layers.Add((series, palette.IconFill)); + return (layers, size); + } + + default: + return ComposeStoplight(descriptor, palette, layers); + } + } + + private static (List<(SKPath Path, SKColor Color)>, SKSize) ComposeStoplight(LiberateIconDescriptor descriptor, LiberateIconPalette palette, List<(SKPath, SKColor)> layers) + { + var body = ParsePath(LiberateIconGeometry.StoplightBody); + var bodyBounds = body.TightBounds; + var bodyScale = LiberateIconGeometry.StoplightHeight / bodyBounds.Height; + var stoplightWidth = bodyBounds.Width * bodyScale; + body.Transform(ScaleThenTranslate(bodyScale, -bodyBounds.Left * bodyScale, -bodyBounds.Top * bodyScale)); + + var lampTop = descriptor.Lamp switch + { + StoplightLamp.Red => LiberateIconGeometry.RedLampTop, + StoplightLamp.Yellow => LiberateIconGeometry.YellowLampTop, + _ => LiberateIconGeometry.GreenLampTop + }; + + //The lamp goes under the body, so it shows through the bezel cut out of it. + var lamp = new SKPath(); + lamp.AddRect(SKRect.Create(LiberateIconGeometry.LampLeft, lampTop, LiberateIconGeometry.LampWidth, LiberateIconGeometry.LampHeight)); + layers.Add((lamp, palette.Lamp(descriptor.Lamp))); + layers.Add((body, palette.IconFill)); + + var width = stoplightWidth; + + if (descriptor.Pdf is not PdfOverlay.None) + { + var pdf = ParsePath(LiberateIconGeometry.PdfDocument); + if (descriptor.Pdf is PdfOverlay.NotDownloaded) + pdf = Union(pdf, ParsePath(LiberateIconGeometry.PdfDownArrow)); + + //Scale to a fixed width, then center vertically, so the document glyph is the same size + //whether or not the download arrow hangs beneath it. + var pdfBounds = pdf.TightBounds; + var pdfScale = LiberateIconGeometry.PdfWidth / pdfBounds.Width; + var left = stoplightWidth + LiberateIconGeometry.PdfLeftMargin; + var top = (LiberateIconGeometry.StoplightHeight - pdfBounds.Height * pdfScale) / 2; + pdf.Transform(ScaleThenTranslate(pdfScale, left - pdfBounds.Left * pdfScale, top - pdfBounds.Top * pdfScale)); + + layers.Add((pdf, palette.IconFill)); + width = left + LiberateIconGeometry.PdfWidth; + } + + return (layers, new SKSize(width, LiberateIconGeometry.StoplightHeight)); + } + + private static SKPath ParsePath(string svgPathData) + { + var path = SKPath.ParseSvgPathData(svgPathData) + ?? throw new InvalidOperationException($"Unparsable icon path data: {svgPathData}"); + path.FillType = SKPathFillType.EvenOdd; + return path; + } + + private static SKPath Union(SKPath first, SKPath second) + { + using (first) + using (second) + return first.Op(second, SKPathOp.Union) + ?? throw new InvalidOperationException("Could not union icon paths."); + } + + /// Shift a path so its bounds start at the origin, and return those bounds' size. + private static SKSize MoveToOrigin(SKPath path) + { + var bounds = path.TightBounds; + path.Transform(SKMatrix.CreateTranslation(-bounds.Left, -bounds.Top)); + return new SKSize(bounds.Width, bounds.Height); + } + + private static SKMatrix ScaleThenTranslate(float scale, float translateX, float translateY) + => new() + { + ScaleX = scale, + ScaleY = scale, + TransX = translateX, + TransY = translateY, + Persp2 = 1 + }; +}