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 + }; +}