diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a9eb93..8a6f721 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]
### Changed
+- Inline game images preserve full and partial transparency through Kitty transmission, including the desktop window, instead of losing their alpha channel.
+
- The Windows installer and the Linux AppImage are now built with Velopack, which is what lets them update. Windows
installs per user under `%LocalAppData%\SharpMUTerm`, adds the `sharpmuterm` folder to PATH and installs the Symbols
Nerd Font Mono font, with no questions asked. If you installed an earlier version with the old installer, uninstall
diff --git a/src/SharpMUTerm.Tui/ImageDecoder.cs b/src/SharpMUTerm.Tui/ImageDecoder.cs
index ea7ba1c..ab1321b 100644
--- a/src/SharpMUTerm.Tui/ImageDecoder.cs
+++ b/src/SharpMUTerm.Tui/ImageDecoder.cs
@@ -50,23 +50,44 @@ public static PixelBuffer Decode(byte[] bytes)
}
}
- private static PixelBuffer DecodeWithImageSharp(byte[] bytes)
+ /// Decode without the framework's RGB-only PixelBuffer discarding alpha.
+ public static RgbaPicture DecodeRgba(byte[] bytes)
{
- var options = new DecoderOptions
+ ArgumentNullException.ThrowIfNull(bytes);
+ try
+ {
+ using var input = new MemoryStream(bytes, writable: false);
+ var info = StbImageSharp.ImageInfo.FromStream(input);
+ if (info is null)
+ {
+ throw new UnsupportedImageFormatException();
+ }
+ if ((long)info.Value.Width * info.Value.Height > MaxPixels)
+ {
+ throw new InvalidImageContentException("Image exceeds the decode ceiling.");
+ }
+ var image = StbImageSharp.ImageResult.FromMemory(bytes, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
+ return new RgbaPicture(image.Width, image.Height, image.Data);
+ }
+ catch (UnsupportedImageFormatException)
{
- Configuration = WebPOnly,
- MaxFrames = 1,
- SkipMetadata = true,
- };
+ return DecodeWebPRgba(bytes);
+ }
+ }
+
+ private static RgbaPicture DecodeWebPRgba(byte[] bytes)
+ {
+ var options = new DecoderOptions { Configuration = WebPOnly, MaxFrames = 1, SkipMetadata = true };
var info = Image.Identify(options, bytes);
if ((long)info.Width * info.Height > MaxPixels)
{
- throw new InvalidImageContentException($"{info.Width}x{info.Height} is past the decode ceiling.");
+ throw new InvalidImageContentException("Image exceeds the decode ceiling.");
}
-
using var image = Image.Load(options, bytes);
var rgba = new byte[image.Width * image.Height * 4];
image.CopyPixelDataTo(rgba);
- return PixelBuffer.FromRgba32Bytes(rgba, image.Width, image.Height);
+ return new RgbaPicture(image.Width, image.Height, rgba);
}
+
+ private static PixelBuffer DecodeWithImageSharp(byte[] bytes) => DecodeWebPRgba(bytes).Pixels;
}
diff --git a/src/SharpMUTerm.Tui/InlineImageHost.cs b/src/SharpMUTerm.Tui/InlineImageHost.cs
index bdfae50..e58f190 100644
--- a/src/SharpMUTerm.Tui/InlineImageHost.cs
+++ b/src/SharpMUTerm.Tui/InlineImageHost.cs
@@ -235,7 +235,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
/// fetched and decoded now. Null when it cannot be had, and a failure is not remembered, so the next
/// mention tries again.
///
- private Task SourceFor(string url)
+ private Task SourceFor(string url)
{
lock (_gate)
{
@@ -252,7 +252,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
_sources[url] = _sourceOrder.AddFirst(source);
source.Picture = Task.Run(async () =>
{
- PixelBuffer? picture = null;
+ RgbaPicture? picture = null;
try
{
picture = await FetchAndDecodeAsync(url).ConfigureAwait(false);
@@ -267,7 +267,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
}
}
- private async Task FetchAndDecodeAsync(string url)
+ private async Task FetchAndDecodeAsync(string url)
{
byte[]? bytes;
await _fetches.WaitAsync(_cts.Token).ConfigureAwait(false);
@@ -291,7 +291,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
try
{
- return ImageDecoder.Decode(bytes);
+ return ImageDecoder.DecodeRgba(bytes);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -300,7 +300,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
}
/// Accounts for a finished fetch: forgets a failure, and trims the cache to its budget.
- private void Settle(Source source, PixelBuffer? picture)
+ private void Settle(Source source, RgbaPicture? picture)
{
lock (_gate)
{
@@ -360,10 +360,10 @@ internal int SourcesHeld
int availableColumns,
int maxRows = InlineImageLayout.MaxRows)
{
- PixelBuffer source;
+ RgbaPicture source;
try
{
- source = ImageDecoder.Decode(bytes);
+ source = ImageDecoder.DecodeRgba(bytes);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -375,7 +375,7 @@ internal int SourcesHeld
/// Sizes a decoded picture to its cell box, keeping the pixels either presentation needs.
internal static DecodedPicture? Fit(
- PixelBuffer source,
+ RgbaPicture source,
InlineImageRequest request,
int availableColumns,
int maxRows = InlineImageLayout.MaxRows)
@@ -390,8 +390,8 @@ internal int SourcesHeld
var height = Math.Min(source.Height, box.Rows * InlineImageLayout.CellPixelHeight);
try
{
- var scaled = PixelScale.Stretch(source, width, height);
- return new DecodedPicture(box, scaled);
+ var scaled = source.Resize(width, height);
+ return new DecodedPicture(box, scaled.Pixels, scaled.Png);
}
catch (ArgumentOutOfRangeException)
{
@@ -444,7 +444,7 @@ private static IReadOnlyList HalfBlockRows(WebImageLayout.CellBox box, P
try
{
- protocol.TransmitRawRgb((uint)id, Rgb(picture.Pixels), picture.Pixels.Width, picture.Pixels.Height, columns, rows);
+ protocol.TransmitImage((uint)id, picture.Png, columns, rows);
}
catch (NotSupportedException)
{
@@ -454,24 +454,6 @@ private static IReadOnlyList HalfBlockRows(WebImageLayout.CellBox box, P
return InlineImageRows.Kitty(id, columns, rows);
}
- private static byte[] Rgb(PixelBuffer pixels)
- {
- var data = new byte[pixels.Width * pixels.Height * 3];
- var i = 0;
- for (var y = 0; y < pixels.Height; y++)
- {
- for (var x = 0; x < pixels.Width; x++)
- {
- var p = pixels.GetPixel(x, y);
- data[i++] = p.R;
- data[i++] = p.G;
- data[i++] = p.B;
- }
- }
-
- return data;
- }
-
private IReadOnlyList? Cached(string key)
{
lock (_gate)
@@ -507,7 +489,7 @@ private sealed class Source(string url)
{
public string Url { get; } = url;
- public Task Picture { get; set; } = Task.FromResult(null);
+ public Task Picture { get; set; } = Task.FromResult(null);
/// Its size in the cache's budget; zero while the fetch is under way.
public long Bytes { get; set; }
@@ -522,4 +504,4 @@ public void Dispose()
}
/// A decoded picture and the cell box it will fill.
-internal sealed record DecodedPicture(WebImageLayout.CellBox Box, PixelBuffer Pixels);
+internal sealed record DecodedPicture(WebImageLayout.CellBox Box, PixelBuffer Pixels, byte[] Png);
diff --git a/src/SharpMUTerm.Tui/RgbaPicture.cs b/src/SharpMUTerm.Tui/RgbaPicture.cs
new file mode 100644
index 0000000..98606b2
--- /dev/null
+++ b/src/SharpMUTerm.Tui/RgbaPicture.cs
@@ -0,0 +1,32 @@
+using SharpConsoleUI.Imaging;
+using SharpMUTerm.Core.Avatars;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace SharpMUTerm.Tui;
+
+/// Straight RGBA pixels retained until Kitty PNG transmission; RGB is only a fallback.
+internal sealed record RgbaPicture(int Width, int Height, byte[] Data)
+{
+ public PixelBuffer Pixels => PixelBuffer.FromRgba32Bytes(Data, Width, Height);
+
+ public byte[] Png => PngWriter.Rgba(Width, Height, Data);
+
+ public RgbaPicture Resize(int width, int height)
+ {
+ if (width == Width && height == Height)
+ {
+ return this;
+ }
+ using var image = Image.LoadPixelData(Data, Width, Height);
+ // Premultiplied interpolation prevents hidden RGB under transparent pixels bleeding into edges.
+ image.Mutate(x => x.Resize(new ResizeOptions
+ {
+ Size = new Size(width, height), Mode = ResizeMode.Stretch, PremultiplyAlpha = true,
+ }));
+ var data = new byte[width * height * 4];
+ image.CopyPixelDataTo(data);
+ return new RgbaPicture(width, height, data);
+ }
+}
diff --git a/tests/SharpMUTerm.Avalonia.Tests/PictureRenderingTests.cs b/tests/SharpMUTerm.Avalonia.Tests/PictureRenderingTests.cs
index a169097..867f904 100644
--- a/tests/SharpMUTerm.Avalonia.Tests/PictureRenderingTests.cs
+++ b/tests/SharpMUTerm.Avalonia.Tests/PictureRenderingTests.cs
@@ -14,6 +14,55 @@ public class PictureRenderingTests
{
private static readonly SKColor Back = new(0x20, 0x20, 0x20);
+ [Test]
+ [Arguments(0)]
+ [Arguments(128)]
+ [Arguments(255)]
+ public async Task InlineKittyPngAlphaCompositesOverTheCellBackground(int alpha)
+ {
+ const int side = 16;
+ var rgba = new byte[side * side * 4];
+ for (var i = 0; i < rgba.Length; i += 4)
+ {
+ rgba[i] = 255;
+ rgba[i + 3] = (byte)alpha;
+ }
+ var png = SharpMUTerm.Core.Avatars.PngWriter.Rgba(side, side, rgba);
+ using var driver = new AvaloniaConsoleDriver(4, 3);
+ using var host = new SharpMUTerm.Tui.InlineImageHost((_, _) => Task.FromResult(png), run => run());
+ host.Load([new SharpMUTerm.Core.Text.InlineImageRequest("https://example.test/alpha.png", "alpha")],
+ InlineImagePresentation.Kitty, 4, () => driver, (_, _) => { });
+ await host.Pending;
+ await Assert.That(driver.Pictures.TryGet(0xC00000, out var picture)).IsTrue();
+ using var decoded = SKBitmap.FromImage(picture.Image);
+ await Assert.That(decoded.GetPixel(0, 0).Alpha).IsEqualTo((byte)alpha);
+
+ var buffer = new CharacterBuffer(4, 3, Color.Default);
+ buffer.SetCell(1, 1, ToCell(Placeholder(0xC00000, 0, 0)));
+ driver.WriteBufferRegion(0, 1, buffer, 0, 1, 4, Color.Default);
+ driver.Flush();
+ using var fonts = new CellFonts(16);
+ using var surface = SKSurface.Create(new SKImageInfo(4 * fonts.CellWidth, 3 * fonts.CellHeight));
+ new CellRenderer(fonts, SKColors.White, Back).Render(surface.Canvas, driver.Frame, driver.Pictures, caretShown: false);
+ using var pixels = SKBitmap.FromImage(surface.Snapshot());
+ var actual = pixels.GetPixel(fonts.CellWidth + fonts.CellWidth / 2, fonts.CellHeight + fonts.CellHeight / 2);
+ var expectedR = (255 * alpha + Back.Red * (255 - alpha) + 127) / 255;
+ var expectedGB = (Back.Green * (255 - alpha) + 127) / 255;
+ await Assert.That(Math.Abs(actual.Red - expectedR) <= 1).IsTrue();
+ await Assert.That(Math.Abs(actual.Green - expectedGB) <= 1).IsTrue();
+ await Assert.That(Math.Abs(actual.Blue - expectedGB) <= 1).IsTrue();
+ }
+
+ [Test]
+ public async Task ResizingTransparentPixelsDoesNotBleedTheirHiddenColour()
+ {
+ var picture = new SharpMUTerm.Tui.RgbaPicture(2, 1, [255, 0, 0, 255, 0, 0, 255, 0]);
+ var resized = picture.Resize(1, 1);
+ await Assert.That(resized.Data[0]).IsEqualTo((byte)255);
+ await Assert.That(resized.Data[2]).IsEqualTo((byte)0);
+ await Assert.That(Math.Abs(resized.Data[3] - 128) <= 1).IsTrue();
+ }
+
[Test]
public async Task APlaceholderNamesItsPictureRowAndColumn()
{
diff --git a/tests/SharpMUTerm.Tui.Tests/ImageDecoderTests.cs b/tests/SharpMUTerm.Tui.Tests/ImageDecoderTests.cs
index e47c552..b71c1a1 100644
--- a/tests/SharpMUTerm.Tui.Tests/ImageDecoderTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/ImageDecoderTests.cs
@@ -7,6 +7,22 @@ namespace SharpMUTerm.Tui.Tests;
///
public class ImageDecoderTests
{
+ [Test]
+ public async Task KittyDecodePreservesPngAndWebPAlpha()
+ {
+ var png = ImageDecoder.DecodeRgba(TestImages.RgbaPng(16, 12));
+ var webp = ImageDecoder.DecodeRgba(TestImages.WebP(16, 12));
+ await Assert.That(png.Data.SequenceEqual(webp.Data)).IsTrue();
+ await Assert.That(Enumerable.Range(0, 16 * 12).Any(i => png.Data[i * 4 + 3] != 255)).IsTrue();
+ }
+
+ [Test]
+ public async Task KittyDecodeStillRejectsTiffAndInvalidBytes()
+ {
+ await Assert.That(() => ImageDecoder.DecodeRgba(TestImages.Tiff(8, 8))).ThrowsException();
+ await Assert.That(() => ImageDecoder.DecodeRgba([1, 2, 3, 4])).ThrowsException();
+ }
+
[Test]
public async Task TheFrameworkStillRefusesWebP()
{