Files
Libation/Source/LibationUiBase/StatusIcons/StatusImageGenerator.cs
T

237 lines
8.7 KiB
C#

using SkiaSharp;
using System;
using System.Collections.Generic;
namespace LibationUiBase.StatusIcons;
/// <summary>
/// Rasterizes the Liberate column's icons from <see cref="LiberateIconGeometry"/> so that every UI
/// displays the same image. Renderings are cached; there are only a couple dozen of them.
/// </summary>
public static class StatusImageGenerator
{
/// <summary>
/// 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.
/// </summary>
public const int RenderScale = 2;
/// <summary>Render <paramref name="descriptor"/>'s icon to a PNG.</summary>
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<LiberateIconDescriptor, byte[]> 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();
}
/// <summary>Lay the icon's paths out, bottom layer first, in artwork units.</summary>
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:
{
//The icon offers the action, not the state: an expanded series shows a minus to
//collapse it, and a collapsed series shows a plus to expand it.
var series = ParsePath(LiberateIconGeometry.SeriesMinus);
if (descriptor.Kind is LiberateIconKind.SeriesCollapsed)
series = Union(series, ParsePath(LiberateIconGeometry.SeriesPlusBar));
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));
//Sitting flush with the top edge keeps the badge out of the stoplight's height, so a Plus
//title's stoplight is drawn at exactly the same size as a purchased one's.
var badgeRadius = LiberateIconGeometry.PlusBadgeDiameter / 2;
var badgeCenterX = stoplightWidth + LiberateIconGeometry.PlusBadgeCornerOffset;
var badgeCenterY = badgeRadius;
//The badge overlaps whatever it lands on rather than displacing it, so the icon is no wider
//than a purchased one once a PDF is present. What keeps that from looking muddy is the rim:
//a hole cut through everything underneath, so the row - not the stoplight or the PDF -
//shows through the gap around the circle.
if (descriptor.IsPlus)
{
using var rim = BadgeRim(badgeCenterX, badgeCenterY, badgeRadius);
body = Difference(body, rim);
lamp = Difference(lamp, rim);
}
layers.Add((lamp, palette.Lamp(descriptor.Lamp)));
layers.Add((body, palette.IconFill));
var width = descriptor.IsPlus ? badgeCenterX + badgeRadius : 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
//and in the same place whether or not the download arrow hangs beneath it, and whether
//or not the book is a Plus title.
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));
if (descriptor.IsPlus)
{
using var rim = BadgeRim(badgeCenterX, badgeCenterY, badgeRadius);
pdf = Difference(pdf, rim);
}
layers.Add((pdf, palette.IconFill));
width = MathF.Max(width, left + LiberateIconGeometry.PdfWidth);
}
//Drawn last so the badge sits on top of everything it overlaps.
if (descriptor.IsPlus)
{
var badge = new SKPath();
badge.AddCircle(badgeCenterX, badgeCenterY, badgeRadius);
layers.Add((badge, palette.PlusBadge));
layers.Add((PlusGlyph(badgeCenterX, badgeCenterY), palette.PlusBadgeGlyph));
}
return (layers, new SKSize(width, LiberateIconGeometry.StoplightHeight));
}
/// <summary>The hole cut around the Audible Plus badge, separating it from whatever it overlaps.</summary>
private static SKPath BadgeRim(float centerX, float centerY, float badgeRadius)
{
var rim = new SKPath();
rim.AddCircle(centerX, centerY, badgeRadius + LiberateIconGeometry.PlusBadgeRimWidth);
return rim;
}
/// <summary>
/// The plus drawn on the Audible Plus badge, as two overlapping rounded bars. They are wound the
/// same way and filled by the winding rule, so the square where they cross stays filled.
/// </summary>
private static SKPath PlusGlyph(float centerX, float centerY)
{
var extent = LiberateIconGeometry.PlusBadgeDiameter * LiberateIconGeometry.PlusBadgeGlyphExtent;
var thickness = extent * LiberateIconGeometry.PlusBadgeGlyphThickness;
var corner = thickness * 0.15f;
var plus = new SKPath { FillType = SKPathFillType.Winding };
plus.AddRoundRect(SKRect.Create(centerX - extent / 2, centerY - thickness / 2, extent, thickness), corner, corner);
plus.AddRoundRect(SKRect.Create(centerX - thickness / 2, centerY - extent / 2, thickness, extent), corner, corner);
return plus;
}
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.");
}
/// <summary>Cut <paramref name="hole"/> out of <paramref name="path"/>. The caller keeps the hole.</summary>
private static SKPath Difference(SKPath path, SKPath hole)
{
using (path)
return path.Op(hole, SKPathOp.Difference)
?? throw new InvalidOperationException("Could not cut an icon path.");
}
/// <summary>Shift a path so its bounds start at the origin, and return those bounds' size.</summary>
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
};
}