From e2705d6844391ea171fdd112fa220baf0a47bae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:51:22 +0000 Subject: [PATCH 1/4] iTerm2 plays a moving picture as a looping GIF Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP --- MarkupString.Ansi/Emitters/GifWriter.cs | 292 ++++++++++++++++++ .../Emitters/TerminalPictureWriter.cs | 12 +- .../Ansi/TerminalFeatureTests.cs | 175 +++++++++++ 3 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 MarkupString.Ansi/Emitters/GifWriter.cs diff --git a/MarkupString.Ansi/Emitters/GifWriter.cs b/MarkupString.Ansi/Emitters/GifWriter.cs new file mode 100644 index 0000000..7ef0221 --- /dev/null +++ b/MarkupString.Ansi/Emitters/GifWriter.cs @@ -0,0 +1,292 @@ +namespace MarkupString.Ansi; + +/// +/// A small GIF89a writer for moving pictures: every frame a whole picture, one shared palette, looping +/// for ever. For terminals that animate a GIF they are sent (iTerm2, WezTerm) but take no frames one by one. +/// +/// +/// +/// One palette for every frame, made by median cut over the colours of all of them, so a colour does not +/// change from frame to frame and nothing shimmers; up to 255 colours, and one more index for transparency +/// when a pixel is less than half opaque. Colours are binned to five bits a channel, so the palette is chosen +/// from at most 32768 bins and each pixel is mapped by a table look-up rather than a nearest-colour search. +/// There is no dithering: dither noise differs from frame to frame and crawls when the picture moves. +/// +/// +/// Each frame is drawn on a cleared canvas (disposal 2), since every frame is the whole picture and a +/// transparent pixel must not show the frame before it. +/// +/// +internal static class GifWriter +{ + private const int Bins = 32768; + + /// + /// , each × RGBA shown for its + /// duration, as a looping GIF file. + /// + internal static byte[] Encode(IReadOnlyList frames, int width, int height) + { + var histogram = new int[Bins]; + var transparent = false; + foreach (var (rgba, _) in frames) + { + var pixels = rgba.Span; + for (var i = 0; i < pixels.Length; i += 4) + { + if (pixels[i + 3] < 128) transparent = true; + else histogram[Bin(pixels, i)]++; + } + } + + var (palette, lookup) = MedianCut(histogram, transparent ? 255 : 256); + var transparentIndex = palette.Count; + var colours = palette.Count + (transparent ? 1 : 0); + var bits = 1; + while (1 << bits < colours) bits++; + + using var output = new MemoryStream(); + output.Write("GIF89a"u8); + WriteShort(output, width); + WriteShort(output, height); + output.WriteByte((byte)(0x80 | 0x70 | (bits - 1))); // a global colour table of 2^bits entries + output.WriteByte(0); // background index + output.WriteByte(0); // pixel aspect ratio + for (var i = 0; i < 1 << bits; i++) + { + var (r, g, b) = i < palette.Count ? palette[i] : ((byte)0, (byte)0, (byte)0); + output.WriteByte(r); + output.WriteByte(g); + output.WriteByte(b); + } + + // NETSCAPE2.0: loop for ever. + output.Write([0x21, 0xFF, 0x0B, .. "NETSCAPE2.0"u8, 0x03, 0x01, 0x00, 0x00, 0x00]); + + var indices = new byte[width * height]; + var minimumCodeSize = Math.Max(2, bits); + foreach (var (rgba, duration) in frames) + { + var pixels = rgba.Span; + for (int i = 0, p = 0; p < indices.Length; i += 4, p++) + indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[Bin(pixels, i)]; + + // Graphic control: restore to background after the frame, the delay in hundredths, transparency. + var centiseconds = (int)Math.Clamp(Math.Round(duration.TotalMilliseconds / 10), 2, ushort.MaxValue); + output.Write([0x21, 0xF9, 0x04, (byte)((2 << 2) | (transparent ? 1 : 0))]); + WriteShort(output, centiseconds); + output.WriteByte(transparent ? (byte)transparentIndex : (byte)0); + output.WriteByte(0); + + output.WriteByte(0x2C); + WriteShort(output, 0); + WriteShort(output, 0); + WriteShort(output, width); + WriteShort(output, height); + output.WriteByte(0); // no local colour table, not interlaced + output.WriteByte((byte)minimumCodeSize); + WriteLzw(output, indices, minimumCodeSize); + } + + output.WriteByte(0x3B); + return output.ToArray(); + } + + private static int Bin(ReadOnlySpan pixels, int offset) => + (pixels[offset] >> 3) << 10 | (pixels[offset + 1] >> 3) << 5 | pixels[offset + 2] >> 3; + + private static void WriteShort(Stream output, int value) + { + output.WriteByte((byte)value); + output.WriteByte((byte)(value >> 8)); + } + + /// + /// A palette of at most for the bins in , and each + /// bin's index in it: the box of bins with the widest channel, weighted by how many pixels it holds, is split + /// at its median until there are enough boxes, and each box's colour is the average of its pixels. + /// + private static (List<(byte R, byte G, byte B)> Palette, byte[] Lookup) MedianCut(int[] histogram, int maxColours) + { + var used = new List(); + for (var bin = 0; bin < Bins; bin++) + if (histogram[bin] > 0) used.Add(bin); + + var boxes = new List<(int Start, int Length)>(); + var bins = used.ToArray(); + if (bins.Length > 0) boxes.Add((0, bins.Length)); + + while (boxes.Count < maxColours) + { + var best = -1; + long bestScore = 0; + var bestChannel = 0; + for (var b = 0; b < boxes.Count; b++) + { + var (start, length) = boxes[b]; + if (length < 2) continue; + var (channel, range) = WidestChannel(bins.AsSpan(start, length)); + long pixels = 0; + for (var i = start; i < start + length; i++) pixels += histogram[bins[i]]; + var score = range * pixels; + if (range > 0 && score > bestScore) + { + (best, bestScore, bestChannel) = (b, score, channel); + } + } + + if (best < 0) break; + + var (boxStart, boxLength) = boxes[best]; + var span = bins.AsSpan(boxStart, boxLength); + var shift = 10 - bestChannel * 5; + span.Sort((x, y) => ((x >> shift) & 31).CompareTo((y >> shift) & 31)); + + long total = 0; + foreach (var bin in span) total += histogram[bin]; + long running = 0; + var split = 1; + for (var i = 0; i < span.Length - 1; i++) + { + running += histogram[span[i]]; + split = i + 1; + if (running * 2 >= total) break; + } + + boxes[best] = (boxStart, split); + boxes.Add((boxStart + split, boxLength - split)); + } + + var palette = new List<(byte, byte, byte)>(boxes.Count); + var lookup = new byte[Bins]; + foreach (var (start, length) in boxes) + { + long r = 0, g = 0, b = 0, count = 0; + for (var i = start; i < start + length; i++) + { + var bin = bins[i]; + long weight = histogram[bin]; + r += (((bin >> 10) & 31) * 8 + 4) * weight; + g += (((bin >> 5) & 31) * 8 + 4) * weight; + b += ((bin & 31) * 8 + 4) * weight; + count += weight; + lookup[bin] = (byte)palette.Count; + } + + palette.Add(((byte)(r / count), (byte)(g / count), (byte)(b / count))); + } + + return (palette, lookup); + } + + /// Which channel (0 red, 1 green, 2 blue) spans the most bins in , and by how much. + private static (int Channel, int Range) WidestChannel(ReadOnlySpan bins) + { + var (best, bestRange) = (0, -1); + for (var channel = 0; channel < 3; channel++) + { + var shift = 10 - channel * 5; + int low = 31, high = 0; + foreach (var bin in bins) + { + var value = (bin >> shift) & 31; + low = Math.Min(low, value); + high = Math.Max(high, value); + } + + if (high - low > bestRange) (best, bestRange) = (channel, high - low); + } + + return (best, bestRange); + } + + /// + /// LZW-compressed as GIF image data, in sub-blocks of at most 255 bytes and a + /// terminator. The code table is cleared when it is full. + /// + private static void WriteLzw(Stream output, ReadOnlySpan indices, int minimumCodeSize) + { + var clear = 1 << minimumCodeSize; + var end = clear + 1; + var codes = new Dictionary(); + var codeSize = minimumCodeSize + 1; + var last = end; + var bits = new BitWriter(output); + + bits.Write(clear, codeSize); + var current = (int)indices[0]; + for (var i = 1; i < indices.Length; i++) + { + var next = indices[i]; + var key = current << 8 | next; + if (codes.TryGetValue(key, out var known)) + { + current = known; + continue; + } + + bits.Write(current, codeSize); + codes[key] = ++last; + if (last >= 1 << codeSize) codeSize++; + if (last == 4095) + { + bits.Write(clear, codeSize); + codes.Clear(); + codeSize = minimumCodeSize + 1; + last = end; + } + + current = next; + } + + // A decoder adds a table entry on reading this last code too, and widens its codes if that fills the + // size; the clear that follows has to be written at the width it will read. + bits.Write(current, codeSize); + if (++last >= 1 << codeSize) codeSize++; + bits.Write(clear, codeSize); + bits.Write(end, minimumCodeSize + 1); + bits.Flush(); + output.WriteByte(0); + } + + /// Packs codes least significant bit first into GIF sub-blocks. + private sealed class BitWriter(Stream output) + { + private readonly byte[] _block = new byte[255]; + private int _length; + private int _buffer; + private int _count; + + public void Write(int code, int size) + { + _buffer |= code << _count; + _count += size; + while (_count >= 8) + { + Put((byte)_buffer); + _buffer >>= 8; + _count -= 8; + } + } + + public void Flush() + { + if (_count > 0) Put((byte)_buffer); + _buffer = _count = 0; + if (_length > 0) WriteBlock(); + } + + private void Put(byte value) + { + _block[_length++] = value; + if (_length == _block.Length) WriteBlock(); + } + + private void WriteBlock() + { + output.WriteByte((byte)_length); + output.Write(_block, 0, _length); + _length = 0; + } + } +} diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs index d605b21..26e1489 100644 --- a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -257,14 +257,20 @@ private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cel private readonly record struct OverlayKey(TerminalFeatures Method, int Columns, int Rows, int CellWidth, int CellHeight); - /// An iTerm2 inline image, a PNG sized in cells, its shape kept. + /// + /// An iTerm2 inline image sized in cells, its shape kept: a PNG, or for a moving picture a looping GIF of its + /// frames, which the terminal plays itself. + /// private static string EncodeInlineImage(TerminalPicture picture, OverlayKey key) { var (width, height) = PictureScaler.FitWithin(picture.Width, picture.Height, key.Columns * key.CellWidth, key.Rows * key.CellHeight, upscale: false); - var png = PngWriter.Encode(PictureScaler.Scale(picture.Rgba.Span, picture.Width, picture.Height, width, height), width, height); + byte[] Scale(ReadOnlyMemory rgba) => PictureScaler.Scale(rgba.Span, picture.Width, picture.Height, width, height); + var file = picture.Frames.Count > 0 + ? GifWriter.Encode([.. picture.Frames.Select(frame => new TerminalPictureFrame(Scale(frame.Rgba), frame.Duration))], width, height) + : PngWriter.Encode(Scale(picture.Rgba), width, height); return string.Create(CultureInfo.InvariantCulture, - $"{Esc}]1337;File=inline=1;size={png.Length};width={key.Columns};height={key.Rows};preserveAspectRatio=1:{Convert.ToBase64String(png)}\a"); + $"{Esc}]1337;File=inline=1;size={file.Length};width={key.Columns};height={key.Rows};preserveAspectRatio=1:{Convert.ToBase64String(file)}\a"); } /// diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index 682dab9..68920f6 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -280,6 +280,181 @@ public async Task InlineImagesMakeRoomDrawAndStepOverTheCells() await Assert.That(png.TakeLast(4)).IsEquivalentTo(new byte[] { 0xAE, 0x42, 0x60, 0x82 }); } + [Test] + public async Task InlineImagesSendAMovingPictureAsALoopingGif() + { + var red = RedBlue(2, 2).Rgba.ToArray(); + var blue = (byte[])red.Clone(); + (blue[0], blue[2]) = (0, 255); // the top left pixel blue, the bottom row see-through + Array.Clear(blue, 8, 8); + var picture = new TerminalPicture("moving", 2, 2, + [new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(blue, TimeSpan.FromMilliseconds(250))]); + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) { Pictures = new Source(picture) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(4, 2)), options); + var gif = Convert.FromBase64String(Regex.Match(output, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); + var decoded = GifReader.Read(gif); + + await Assert.That(output).Contains($"size={gif.Length};width=4;height=2;"); + await Assert.That(decoded.Loops).IsTrue(); + await Assert.That(decoded.Frames.Select(f => f.Centiseconds)).IsEquivalentTo(new[] { 10, 25 }); + await Assert.That(decoded.Frames[0].Rgba.SequenceEqual(Quantized(red))).IsTrue(); + await Assert.That(decoded.Frames[1].Rgba.SequenceEqual(Quantized(blue))).IsTrue(); + } + + /// + /// A picture of fewer than 256 colours comes back from the GIF exactly, each colour being its own palette entry: + /// big and noisy enough that the LZW table fills, grows its code size to twelve bits and is cleared. + /// + [Test] + public async Task AMovingPictureOfFewColoursRoundTripsThroughTheGif() + { + var random = new Random(11); + var colours = Enumerable.Range(0, 200).Select(_ => (R: Centre(random), G: Centre(random), B: Centre(random))).Distinct().ToArray(); + byte[] Noise() + { + var rgba = new byte[128 * 128 * 4]; + for (var i = 0; i < rgba.Length; i += 4) + { + var (r, g, b) = colours[random.Next(colours.Length)]; + (rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]) = (r, g, b, 255); + } + return rgba; + } + + var frames = new[] { Noise(), Noise(), Noise() }; + var picture = new TerminalPicture("noise", 128, 128, [.. frames.Select(f => new TerminalPictureFrame(f, TimeSpan.FromMilliseconds(40)))]); + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) { Pictures = new Source(picture), CellWidth = 16, CellHeight = 32 }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 20, new PictureCells(8, 4)), options); + var decoded = GifReader.Read(Convert.FromBase64String(Regex.Match(output, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value)); + + await Assert.That(decoded.Width).IsEqualTo(128); + await Assert.That(decoded.Frames.Count).IsEqualTo(3); + for (var i = 0; i < frames.Length; i++) + await Assert.That(decoded.Frames[i].Rgba.SequenceEqual(frames[i])).IsTrue(); + } + + private static byte Centre(Random random) => (byte)(random.Next(32) * 8 + 4); + + /// + /// Each channel moved to the middle of its five-bit bin, as the GIF's palette has it, and a pixel less than half + /// opaque cleared. + /// + private static byte[] Quantized(byte[] rgba) => + [.. rgba.Select((value, i) => rgba[i - i % 4 + 3] < 128 ? (byte)0 : i % 4 == 3 ? (byte)255 : (byte)((value >> 3) * 8 + 4))]; + + /// Just enough of a GIF decoder to read back what the writer makes: a global palette and whole frames. + private static class GifReader + { + public sealed record Frame(int Centiseconds, byte[] Rgba); + + public sealed record Gif(int Width, int Height, bool Loops, List Frames); + + public static Gif Read(byte[] gif) + { + if (Encoding.ASCII.GetString(gif, 0, 6) != "GIF89a") throw new InvalidDataException("not a GIF89a"); + var width = gif[6] | gif[7] << 8; + var height = gif[8] | gif[9] << 8; + var packed = gif[10]; + var tableSize = (packed & 0x80) != 0 ? 1 << ((packed & 7) + 1) : 0; + var palette = gif.AsSpan(13, tableSize * 3).ToArray(); + var at = 13 + tableSize * 3; + var loops = false; + var delay = 0; + int? transparent = null; + var frames = new List(); + while (true) + { + switch (gif[at++]) + { + case 0x21: + var label = gif[at++]; + var block = SubBlocks(gif, ref at); + if (label == 0xFF && Encoding.ASCII.GetString(block, 0, 11) == "NETSCAPE2.0") loops = true; + if (label == 0xF9) + { + delay = block[1] | block[2] << 8; + transparent = (block[0] & 1) != 0 ? block[3] : null; + } + break; + case 0x2C: + if (gif[at + 8] != 0) throw new InvalidDataException("local tables and interlacing are not read"); + at += 9; + var minimumCodeSize = gif[at++]; + var indices = Lzw(SubBlocks(gif, ref at), minimumCodeSize, width * height); + var rgba = new byte[width * height * 4]; + for (var p = 0; p < indices.Length; p++) + { + if (indices[p] == transparent) continue; + rgba[p * 4] = palette[indices[p] * 3]; + rgba[p * 4 + 1] = palette[indices[p] * 3 + 1]; + rgba[p * 4 + 2] = palette[indices[p] * 3 + 2]; + rgba[p * 4 + 3] = 255; + } + frames.Add(new Frame(delay, rgba)); + break; + case 0x3B: + return new Gif(width, height, loops, frames); + default: + throw new InvalidDataException($"unexpected block at {at - 1}"); + } + } + } + + private static byte[] SubBlocks(byte[] gif, ref int at) + { + var data = new List(); + for (int length; (length = gif[at++]) > 0; at += length) data.AddRange(gif.AsSpan(at, length)); + return [.. data]; + } + + private static byte[] Lzw(byte[] data, int minimumCodeSize, int pixels) + { + var clear = 1 << minimumCodeSize; + var end = clear + 1; + var table = new List(); + void Reset() + { + table.Clear(); + for (var i = 0; i < clear; i++) table.Add([(byte)i]); + table.Add([]); + table.Add([]); + } + + Reset(); + var codeSize = minimumCodeSize + 1; + var output = new List(pixels); + byte[]? previous = null; + int bit = 0; + while (true) + { + var code = 0; + for (var i = 0; i < codeSize; i++, bit++) code |= (data[bit >> 3] >> (bit & 7) & 1) << i; + if (code == clear) + { + Reset(); + codeSize = minimumCodeSize + 1; + previous = null; + continue; + } + + if (code == end) break; + byte[] entry; + if (code < table.Count) entry = table[code]; + else if (code == table.Count && previous is not null) entry = [.. previous, previous[0]]; + else throw new InvalidDataException($"code {code} past the table ({table.Count})"); + output.AddRange(entry); + if (previous is not null && table.Count < 4096) table.Add([.. previous, entry[0]]); + if (table.Count == 1 << codeSize && codeSize < 12) codeSize++; + previous = entry; + } + + if (output.Count != pixels) throw new InvalidDataException($"{output.Count} pixels, not {pixels}"); + return [.. output]; + } + } + [Test] public async Task SixelIsTheCellsSizeInWholeBands() { From d7a7a37102fcb6700ebdfaa8278888c4d63c0f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:56:38 +0000 Subject: [PATCH 2/4] Name the terminals pictures are drawn for; moving pictures are opt-in TerminalProfile lists the terminals this package knows, what each can be sent, and how (iTerm2's 1 MiB sequence limit and its multipart transfer). AnsiOutputOptions.For sends a terminal only what the player turned on of what it can do. MovingPictures is a feature of its own, so a picture plays only for a client that asked. A frame of 10 ms or less is shown for 100 ms, as browsers do. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP --- MarkupString.Ansi/AnsiOutputOptions.cs | 17 ++ MarkupString.Ansi/Emitters/GifWriter.cs | 10 +- .../Emitters/TerminalPictureWriter.cs | 70 ++++++-- MarkupString.Ansi/PublicAPI.Unshipped.txt | 43 +++++ MarkupString.Ansi/TerminalFeatures.cs | 7 + MarkupString.Ansi/TerminalPicture.cs | 14 +- MarkupString.Ansi/TerminalProfile.cs | 162 ++++++++++++++++++ .../Ansi/TerminalFeatureTests.cs | 85 ++++++++- .../Ansi/TerminalProfileTests.cs | 42 +++++ 9 files changed, 425 insertions(+), 25 deletions(-) create mode 100644 MarkupString.Ansi/TerminalProfile.cs create mode 100644 MarkupString.Tests/Ansi/TerminalProfileTests.cs diff --git a/MarkupString.Ansi/AnsiOutputOptions.cs b/MarkupString.Ansi/AnsiOutputOptions.cs index 4cfa441..891cf20 100644 --- a/MarkupString.Ansi/AnsiOutputOptions.cs +++ b/MarkupString.Ansi/AnsiOutputOptions.cs @@ -21,4 +21,21 @@ public sealed record AnsiOutputOptions( /// The height of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 20 when unknown, as is a value below 1. public int CellHeight { get; init => field = value > 0 ? value : 20; } = 20; + + /// + /// The client's terminal, when it is known, for how it wants what it is sent (how long an inline image may be, + /// and whether one is sent in parts). Without one, an inline image is held to iTerm2's limit and sent whole. + /// It sends nothing does not name. + /// + public TerminalProfile? Terminal { get; init; } + + /// + /// What a client using is sent: of what it can do, only what the player turned on + /// (). + /// + public static AnsiOutputOptions For(TerminalProfile terminal, TerminalFeatures chosen, AnsiColorDepth colorDepth = AnsiColorDepth.TrueColor) + { + ArgumentNullException.ThrowIfNull(terminal); + return new AnsiOutputOptions(colorDepth, terminal.Allow(chosen)) { Terminal = terminal }; + } } diff --git a/MarkupString.Ansi/Emitters/GifWriter.cs b/MarkupString.Ansi/Emitters/GifWriter.cs index 7ef0221..5bdc042 100644 --- a/MarkupString.Ansi/Emitters/GifWriter.cs +++ b/MarkupString.Ansi/Emitters/GifWriter.cs @@ -29,9 +29,9 @@ internal static byte[] Encode(IReadOnlyList frames, int wi { var histogram = new int[Bins]; var transparent = false; - foreach (var (rgba, _) in frames) + foreach (var frame in frames) { - var pixels = rgba.Span; + var pixels = frame.Rgba.Span; for (var i = 0; i < pixels.Length; i += 4) { if (pixels[i + 3] < 128) transparent = true; @@ -65,14 +65,14 @@ internal static byte[] Encode(IReadOnlyList frames, int wi var indices = new byte[width * height]; var minimumCodeSize = Math.Max(2, bits); - foreach (var (rgba, duration) in frames) + foreach (var frame in frames) { - var pixels = rgba.Span; + var pixels = frame.Rgba.Span; for (int i = 0, p = 0; p < indices.Length; i += 4, p++) indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[Bin(pixels, i)]; // Graphic control: restore to background after the frame, the delay in hundredths, transparency. - var centiseconds = (int)Math.Clamp(Math.Round(duration.TotalMilliseconds / 10), 2, ushort.MaxValue); + var centiseconds = (int)Math.Clamp(Math.Round(frame.Shown.TotalMilliseconds / 10), 2, ushort.MaxValue); output.Write([0x21, 0xF9, 0x04, (byte)((2 << 2) | (transparent ? 1 : 0))]); WriteShort(output, centiseconds); output.WriteByte(transparent ? (byte)transparentIndex : (byte)0); diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs index 26e1489..b80ab6c 100644 --- a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -126,7 +126,8 @@ private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture { var id = KittyId(picture, cells); if (options.Pictures?.MarkTransmitted(id) != false) - ahead.Write(PictureEncodings.GetOrAdd(picture, new KittyKey(id, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight), EncodeKitty)); + ahead.Write(PictureEncodings.GetOrAdd(picture, + new KittyKey(id, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight, Moving(picture, options)), EncodeKitty)); // The image id is the placeholder's foreground. It is written at truecolor whatever the client's depth: // it is not a colour anyone sees, and a terminal that reads Kitty graphics reads 24-bit SGR. @@ -147,7 +148,11 @@ private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture SgrWriter.Transition(placeholder, effective, output); } - private readonly record struct KittyKey(uint Id, int Columns, int Rows, int CellWidth, int CellHeight); + private readonly record struct KittyKey(uint Id, int Columns, int Rows, int CellWidth, int CellHeight, bool Moving); + + /// Whether is sent moving: it has frames, and the client asked for them. + private static bool Moving(TerminalPicture picture, AnsiOutputOptions options) => + picture.Frames.Count > 1 && (options.Features & TerminalFeatures.MovingPictures) != 0; /// /// The picture as a Kitty image with a virtual placement of the cells' size, sent quietly (q=2) so @@ -175,17 +180,17 @@ private static string EncodeKitty(TerminalPicture picture, KittyKey key) AppendKittyChunks(text, picture.Rgba.Span, picture, width, height, string.Create(CultureInfo.InvariantCulture, $"a=T,U=1,i={key.Id},f=100,c={key.Columns},r={key.Rows},q=2"), ""); - if (picture.Frames.Count > 1) + if (key.Moving) { for (var frame = 1; frame < picture.Frames.Count; frame++) { AppendKittyChunks(text, picture.Frames[frame].Rgba.Span, picture, width, height, - string.Create(CultureInfo.InvariantCulture, $"a=f,i={key.Id},f=100,X=1,z={KittyGap(picture.Frames[frame].Duration)},q=2"), + string.Create(CultureInfo.InvariantCulture, $"a=f,i={key.Id},f=100,X=1,z={KittyGap(picture.Frames[frame])},q=2"), "a=f,"); } text.Append(CultureInfo.InvariantCulture, - $"{Esc}_Ga=a,i={key.Id},r=1,z={KittyGap(picture.Frames[0].Duration)},q=2{StringTerminator}"); + $"{Esc}_Ga=a,i={key.Id},r=1,z={KittyGap(picture.Frames[0])},q=2{StringTerminator}"); text.Append(CultureInfo.InvariantCulture, $"{Esc}_Ga=a,i={key.Id},s=3,v=1,q=2{StringTerminator}"); } @@ -193,10 +198,10 @@ private static string EncodeKitty(TerminalPicture picture, KittyKey key) } /// - /// A frame's duration as Kitty's gap in milliseconds: at least one, since Kitty ignores a zero gap and - /// reads a negative one as no gap at all. + /// How long a frame is shown, as Kitty's gap in milliseconds: never zero, which Kitty ignores, nor negative, + /// which it reads as a frame not shown at all. /// - private static int KittyGap(TimeSpan duration) => (int)Math.Clamp(duration.TotalMilliseconds, 1, int.MaxValue); + private static int KittyGap(TerminalPictureFrame frame) => (int)Math.Clamp(frame.Shown.TotalMilliseconds, 1, int.MaxValue); /// /// scaled to × , as a PNG in base64 @@ -246,7 +251,9 @@ private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cel for (var row = 0; row < cells.Rows; row++) ahead.Write(Esc + "D"); ahead.Write($"{Esc}[{cells.Rows}A"); ahead.Write(Esc + "7"); - var key = new OverlayKey(method, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight); + var key = new OverlayKey(method, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight, + Moving(picture, options), options.Terminal?.InlineImageLimit ?? TerminalProfile.ITerm2.InlineImageLimit, + options.Terminal?.MultipartInlineImages ?? false); ahead.Write(PictureEncodings.GetOrAdd(picture, key, static (picture, key) => key.Method == TerminalFeatures.InlineImages ? EncodeInlineImage(picture, key) : EncodeSixel(picture, key))); ahead.Write(Esc + "8"); @@ -255,22 +262,55 @@ private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cel output.Write($"{Esc}[{cells.Columns}C"); } - private readonly record struct OverlayKey(TerminalFeatures Method, int Columns, int Rows, int CellWidth, int CellHeight); + private readonly record struct OverlayKey(TerminalFeatures Method, int Columns, int Rows, int CellWidth, int CellHeight, + bool Moving, int Limit, bool Multipart); /// /// An iTerm2 inline image sized in cells, its shape kept: a PNG, or for a moving picture a looping GIF of its /// frames, which the terminal plays itself. /// + /// + /// iTerm2 refuses a sequence longer than a mebibyte (). A file + /// that would make one longer is sent in parts (MultipartFile, FilePart, FileEnd) to a + /// terminal that reads them; to one that does not, a moving picture too big for one sequence is sent still. + /// private static string EncodeInlineImage(TerminalPicture picture, OverlayKey key) { var (width, height) = PictureScaler.FitWithin(picture.Width, picture.Height, key.Columns * key.CellWidth, key.Rows * key.CellHeight, upscale: false); byte[] Scale(ReadOnlyMemory rgba) => PictureScaler.Scale(rgba.Span, picture.Width, picture.Height, width, height); - var file = picture.Frames.Count > 0 - ? GifWriter.Encode([.. picture.Frames.Select(frame => new TerminalPictureFrame(Scale(frame.Rgba), frame.Duration))], width, height) - : PngWriter.Encode(Scale(picture.Rgba), width, height); - return string.Create(CultureInfo.InvariantCulture, - $"{Esc}]1337;File=inline=1;size={file.Length};width={key.Columns};height={key.Rows};preserveAspectRatio=1:{Convert.ToBase64String(file)}\a"); + string Arguments(byte[] file) => string.Create(CultureInfo.InvariantCulture, + $"inline=1;size={file.Length};width={key.Columns};height={key.Rows};preserveAspectRatio=1"); + + if (key.Moving) + { + var gif = GifWriter.Encode([.. picture.Frames.Select(frame => new TerminalPictureFrame(Scale(frame.Rgba), frame.Duration))], width, height); + if (InlineImage(Arguments(gif), Convert.ToBase64String(gif), key) is { } moving) return moving; + } + + var png = PngWriter.Encode(Scale(picture.Rgba), width, height); + var payload = Convert.ToBase64String(png); + return InlineImage(Arguments(png), payload, key) ?? $"{Esc}]1337;File={Arguments(png)}:{payload}\a"; + } + + /// + /// The file as one sequence, or in parts when one would be over and the terminal + /// reads parts; null when it can be neither. + /// + private static string? InlineImage(string arguments, string payload, OverlayKey key) + { + var single = $"{Esc}]1337;File={arguments}:{payload}\a"; + if (key.Limit <= 0 || single.Length <= key.Limit) return single; + if (!key.Multipart) return null; + + // Each part is its own sequence, under the limit too; base64 in whole quanta of four. + var part = (key.Limit - 32) / 4 * 4; + var text = new StringBuilder(payload.Length + payload.Length / part * 32 + 128); + text.Append(Esc).Append("]1337;MultipartFile=").Append(arguments).Append('\a'); + for (var offset = 0; offset < payload.Length; offset += part) + text.Append(Esc).Append("]1337;FilePart=").Append(payload, offset, Math.Min(part, payload.Length - offset)).Append('\a'); + text.Append(Esc).Append("]1337;FileEnd\a"); + return text.ToString(); } /// diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 6b06033..4bd6195 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -14,6 +14,8 @@ MarkupString.Ansi.AnsiOutputOptions.Features.get -> MarkupString.Ansi.TerminalFe MarkupString.Ansi.AnsiOutputOptions.Features.init -> void MarkupString.Ansi.AnsiOutputOptions.Pictures.get -> MarkupString.Ansi.ITerminalPictureSource? MarkupString.Ansi.AnsiOutputOptions.Pictures.init -> void +MarkupString.Ansi.AnsiOutputOptions.Terminal.get -> MarkupString.Ansi.TerminalProfile? +MarkupString.Ansi.AnsiOutputOptions.Terminal.init -> void MarkupString.Ansi.AnsiSetEmitter.AnsiSetEmitter(MarkupString.Ansi.AnsiOutputOptions! options) -> void MarkupString.Ansi.ITerminalPictureSource MarkupString.Ansi.ITerminalPictureSource.MarkTransmitted(uint imageId) -> bool @@ -24,6 +26,7 @@ MarkupString.Ansi.TerminalFeatures.CommandLinks = 2 -> MarkupString.Ansi.Termina MarkupString.Ansi.TerminalFeatures.Hyperlinks = 1 -> MarkupString.Ansi.TerminalFeatures MarkupString.Ansi.TerminalFeatures.InlineImages = 8 -> MarkupString.Ansi.TerminalFeatures MarkupString.Ansi.TerminalFeatures.KittyGraphics = 4 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.MovingPictures = 64 -> MarkupString.Ansi.TerminalFeatures MarkupString.Ansi.TerminalFeatures.None = 0 -> MarkupString.Ansi.TerminalFeatures MarkupString.Ansi.TerminalFeatures.Pictures = MarkupString.Ansi.TerminalFeatures.KittyGraphics | MarkupString.Ansi.TerminalFeatures.InlineImages | MarkupString.Ansi.TerminalFeatures.Sixel | MarkupString.Ansi.TerminalFeatures.BlockArt -> MarkupString.Ansi.TerminalFeatures MarkupString.Ansi.TerminalFeatures.Sixel = 16 -> MarkupString.Ansi.TerminalFeatures @@ -44,14 +47,54 @@ MarkupString.Ansi.TerminalPictureFrame.Rgba.get -> System.ReadOnlyMemory MarkupString.Ansi.TerminalPictureFrame.Rgba.init -> void MarkupString.Ansi.TerminalPictureFrame.TerminalPictureFrame() -> void MarkupString.Ansi.TerminalPictureFrame.TerminalPictureFrame(System.ReadOnlyMemory Rgba, System.TimeSpan Duration) -> void +MarkupString.Ansi.TerminalProfile +MarkupString.Ansi.TerminalProfile.$() -> MarkupString.Ansi.TerminalProfile! +MarkupString.Ansi.TerminalProfile.Allow(MarkupString.Ansi.TerminalFeatures chosen) -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalProfile.Deconstruct(out string! Id, out string! Name, out MarkupString.Ansi.TerminalFeatures Features) -> void +MarkupString.Ansi.TerminalProfile.Equals(MarkupString.Ansi.TerminalProfile? other) -> bool +MarkupString.Ansi.TerminalProfile.Features.get -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalProfile.Features.init -> void +MarkupString.Ansi.TerminalProfile.Id.get -> string! +MarkupString.Ansi.TerminalProfile.Id.init -> void +MarkupString.Ansi.TerminalProfile.InlineImageLimit.get -> int +MarkupString.Ansi.TerminalProfile.InlineImageLimit.init -> void +MarkupString.Ansi.TerminalProfile.MultipartInlineImages.get -> bool +MarkupString.Ansi.TerminalProfile.MultipartInlineImages.init -> void +MarkupString.Ansi.TerminalProfile.Name.get -> string! +MarkupString.Ansi.TerminalProfile.Name.init -> void +MarkupString.Ansi.TerminalProfile.Reports.get -> System.Collections.Generic.IReadOnlyList! +MarkupString.Ansi.TerminalProfile.Reports.init -> void +MarkupString.Ansi.TerminalProfile.TerminalProfile(string! Id, string! Name, MarkupString.Ansi.TerminalFeatures Features) -> void override MarkupString.Ansi.AnsiOutputOptions.Equals(object? obj) -> bool override MarkupString.Ansi.AnsiOutputOptions.GetHashCode() -> int override MarkupString.Ansi.AnsiOutputOptions.ToString() -> string! ~override MarkupString.Ansi.TerminalPictureFrame.Equals(object obj) -> bool override MarkupString.Ansi.TerminalPictureFrame.GetHashCode() -> int ~override MarkupString.Ansi.TerminalPictureFrame.ToString() -> string +override MarkupString.Ansi.TerminalProfile.Equals(object? obj) -> bool +override MarkupString.Ansi.TerminalProfile.GetHashCode() -> int +override MarkupString.Ansi.TerminalProfile.ToString() -> string! +static MarkupString.Ansi.AnsiOutputOptions.For(MarkupString.Ansi.TerminalProfile! terminal, MarkupString.Ansi.TerminalFeatures chosen, MarkupString.Ansi.AnsiColorDepth colorDepth = MarkupString.Ansi.AnsiColorDepth.TrueColor) -> MarkupString.Ansi.AnsiOutputOptions! static MarkupString.Ansi.AnsiOutputOptions.operator !=(MarkupString.Ansi.AnsiOutputOptions? left, MarkupString.Ansi.AnsiOutputOptions? right) -> bool static MarkupString.Ansi.AnsiOutputOptions.operator ==(MarkupString.Ansi.AnsiOutputOptions? left, MarkupString.Ansi.AnsiOutputOptions? right) -> bool static MarkupString.Ansi.AnsiRegistration.WithAnsiOutput(this MarkupString.MarkupRegistry! registry, MarkupString.Ansi.AnsiOutputOptions! options) -> MarkupString.MarkupRegistry! static MarkupString.Ansi.TerminalPictureFrame.operator !=(MarkupString.Ansi.TerminalPictureFrame left, MarkupString.Ansi.TerminalPictureFrame right) -> bool static MarkupString.Ansi.TerminalPictureFrame.operator ==(MarkupString.Ansi.TerminalPictureFrame left, MarkupString.Ansi.TerminalPictureFrame right) -> bool +static MarkupString.Ansi.TerminalProfile.Alacritty.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Contour.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Find(string? id) -> MarkupString.Ansi.TerminalProfile? +static MarkupString.Ansi.TerminalProfile.Foot.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Ghostty.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.ITerm2.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Identify(string? reported) -> MarkupString.Ansi.TerminalProfile? +static MarkupString.Ansi.TerminalProfile.Kitty.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Known.get -> System.Collections.Generic.IReadOnlyList! +static MarkupString.Ansi.TerminalProfile.Konsole.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Mintty.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.VsCode.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Vte.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.WezTerm.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.WindowsTerminal.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.XTerm.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.operator !=(MarkupString.Ansi.TerminalProfile? left, MarkupString.Ansi.TerminalProfile? right) -> bool +static MarkupString.Ansi.TerminalProfile.operator ==(MarkupString.Ansi.TerminalProfile? left, MarkupString.Ansi.TerminalProfile? right) -> bool diff --git a/MarkupString.Ansi/TerminalFeatures.cs b/MarkupString.Ansi/TerminalFeatures.cs index 15c0704..0424100 100644 --- a/MarkupString.Ansi/TerminalFeatures.cs +++ b/MarkupString.Ansi/TerminalFeatures.cs @@ -47,6 +47,13 @@ public enum TerminalFeatures /// BlockArt = 32, + /// + /// A moving picture plays, through whichever of or draws + /// it: Kitty is sent its frames, iTerm2 a looping GIF. Without it, and by every other way of drawing, a moving + /// picture is its first frame. Not a way of drawing on its own, so not part of . + /// + MovingPictures = 64, + /// Every way of drawing a picture. Pictures = KittyGraphics | InlineImages | Sixel | BlockArt, } diff --git a/MarkupString.Ansi/TerminalPicture.cs b/MarkupString.Ansi/TerminalPicture.cs index 273e777..6d9f0db 100644 --- a/MarkupString.Ansi/TerminalPicture.cs +++ b/MarkupString.Ansi/TerminalPicture.cs @@ -81,8 +81,18 @@ private static ReadOnlyMemory FirstOf(IReadOnlyList /// One frame of a moving picture: the whole picture as it is shown, and for how long. /// The pixels: RGBA, row by row from the top left. -/// How long the frame is shown before the next. -public readonly record struct TerminalPictureFrame(ReadOnlyMemory Rgba, TimeSpan Duration); +/// +/// How long the frame is shown before the next. 10 milliseconds or less is shown for 100, as browsers do: a GIF +/// saved with no delay, or one too short to see, was made to be played that way. +/// +public readonly record struct TerminalPictureFrame(ReadOnlyMemory Rgba, TimeSpan Duration) +{ + private static readonly TimeSpan Shortest = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan Unset = TimeSpan.FromMilliseconds(100); + + /// How long the frame is actually shown. + internal TimeSpan Shown => Duration <= Shortest ? Unset : Duration; +} /// /// Where a terminal render finds a picture's pixels, and what it knows about the one connection it is diff --git a/MarkupString.Ansi/TerminalProfile.cs b/MarkupString.Ansi/TerminalProfile.cs new file mode 100644 index 0000000..4f02212 --- /dev/null +++ b/MarkupString.Ansi/TerminalProfile.cs @@ -0,0 +1,162 @@ +namespace MarkupString.Ansi; + +/// +/// A terminal this package knows: what it can be sent beyond colour, and how it wants it. A host names the +/// client's terminal (from what it reported, with , or from the player, with +/// ) and sends it what the player has turned on of what it can do (). +/// +/// +/// +/// is what the terminal can do, not what it is sent: every feature here is one a player +/// turns on. A terminal is often reached through something that changes what it can show (a multiplexer, an +/// ssh hop, a setting left off), and only the player sees the result. +/// +/// +/// A host may describe a terminal this package does not know with a profile of its own. +/// +/// +/// The short name a player gives it, such as kitty or windows-terminal. +/// Its name as written, such as Windows Terminal. +/// Everything it can be sent. +public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Features) +{ + /// + /// The names it reports for itself, compared without case: a telnet terminal type, or the name in its reply to + /// XTVERSION (CSI > q), which is read up to its first space or parenthesis. + /// + public IReadOnlyList Reports { get; init; } = []; + + /// + /// The longest single iTerm2 inline-image sequence it takes, in characters; 0 when it sets none. + /// + public int InlineImageLimit { get; init; } + + /// Whether it reads an inline image sent in parts (MultipartFile, FilePart, FileEnd). + public bool MultipartInlineImages { get; init; } + + /// What it is sent of , the features the player turned on. + public TerminalFeatures Allow(TerminalFeatures chosen) => Features & chosen; + + /// kitty: Kitty graphics with Unicode placeholders, and their animation. + public static TerminalProfile Kitty { get; } = new("kitty", "kitty", + TerminalFeatures.Hyperlinks | TerminalFeatures.KittyGraphics | TerminalFeatures.MovingPictures | TerminalFeatures.BlockArt) + { + Reports = ["kitty", "xterm-kitty"], + }; + + /// Ghostty: Kitty graphics with Unicode placeholders. + public static TerminalProfile Ghostty { get; } = new("ghostty", "Ghostty", + TerminalFeatures.Hyperlinks | TerminalFeatures.KittyGraphics | TerminalFeatures.BlockArt) + { + Reports = ["ghostty", "xterm-ghostty"], + }; + + /// WezTerm: iTerm2 inline images, which play a GIF, and sixel. + public static TerminalProfile WezTerm { get; } = new("wezterm", "WezTerm", + TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures | TerminalFeatures.Sixel + | TerminalFeatures.BlockArt) + { + Reports = ["wezterm"], + }; + + /// + /// iTerm2: its own inline images, which play a GIF, one sequence at most a mebibyte, and since 3.5 sent in parts. + /// + public static TerminalProfile ITerm2 { get; } = new("iterm2", "iTerm2", + TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures | TerminalFeatures.Sixel + | TerminalFeatures.BlockArt) + { + Reports = ["iterm2", "iterm.app"], + InlineImageLimit = 1_048_576, + MultipartInlineImages = true, + }; + + /// Konsole: iTerm2 inline images and sixel. + public static TerminalProfile Konsole { get; } = new("konsole", "Konsole", + TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["konsole"], + InlineImageLimit = 1_048_576, + }; + + /// foot: sixel. + public static TerminalProfile Foot { get; } = new("foot", "foot", + TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["foot", "foot-extra"], + }; + + /// xterm: sixel when it is started as a VT340 (-ti vt340). + public static TerminalProfile XTerm { get; } = new("xterm", "xterm", + TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["xterm"], + }; + + /// Windows Terminal: sixel since 1.22. + public static TerminalProfile WindowsTerminal { get; } = new("windows-terminal", "Windows Terminal", + TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["ms-terminal"], + }; + + /// mintty: iTerm2 inline images and sixel. + public static TerminalProfile Mintty { get; } = new("mintty", "mintty", + TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["mintty"], + InlineImageLimit = 1_048_576, + }; + + /// Visual Studio Code's terminal: iTerm2 inline images and sixel, once its image setting is on. + public static TerminalProfile VsCode { get; } = new("vscode", "Visual Studio Code", + TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["vscode"], + InlineImageLimit = 1_048_576, + }; + + /// Contour: sixel. + public static TerminalProfile Contour { get; } = new("contour", "Contour", + TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["contour"], + }; + + /// Alacritty: links, and no pictures but half blocks. + public static TerminalProfile Alacritty { get; } = new("alacritty", "Alacritty", + TerminalFeatures.Hyperlinks | TerminalFeatures.BlockArt) + { + Reports = ["alacritty"], + }; + + /// A VTE terminal (GNOME Terminal, Tilix, Terminator and others): links, and half blocks. + public static TerminalProfile Vte { get; } = new("vte", "GNOME Terminal (VTE)", + TerminalFeatures.Hyperlinks | TerminalFeatures.BlockArt) + { + Reports = ["vte"], + }; + + /// Every terminal this package knows. + public static IReadOnlyList Known { get; } = + [Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Alacritty, Vte]; + + /// The known terminal a player calls , compared without case, or null. + public static TerminalProfile? Find(string? id) => + string.IsNullOrWhiteSpace(id) ? null : Known.FirstOrDefault(profile => string.Equals(profile.Id, id.Trim(), StringComparison.OrdinalIgnoreCase)); + + /// + /// The known terminal that reported (a terminal type, or an XTVERSION reply such + /// as kitty(0.35.2) or WezTerm 20240203-110809-5046fc22), or null. Only the name is read, up to + /// its first space or parenthesis, and it must be the whole of one of : the + /// xterm-256color nearly every client sends names no terminal. + /// + public static TerminalProfile? Identify(string? reported) + { + if (string.IsNullOrWhiteSpace(reported)) return null; + var name = reported.Trim(); + var end = name.IndexOfAny([' ', '(']); + if (end > 0) name = name[..end]; + return Known.FirstOrDefault(profile => profile.Reports.Any(report => string.Equals(report, name, StringComparison.OrdinalIgnoreCase))); + } +} diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index 68920f6..769059c 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -202,7 +202,7 @@ public async Task KittySendsEachFrameOfAMovingPictureThenStartsIt() var picture = new TerminalPicture("moving", 2, 2, [new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(blue, TimeSpan.FromMilliseconds(250))]); var source = new Source(picture); - var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = source }; + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics | TerminalFeatures.MovingPictures) { Pictures = source }; var laid = Laid(new Figure(Cat, MarkupText.Empty), 10); var first = RenderString(laid, options); @@ -217,6 +217,40 @@ await Assert.That(first.IndexOf($"a=a,i={id},s=3,v=1,q=2", StringComparison.Ordi await Assert.That(second).DoesNotContain("a=f,").And.DoesNotContain("a=a,"); } + [Test] + public async Task AMovingPictureStaysStillUnlessTheClientAskedForMovingPictures() + { + var red = RedBlue(2, 2).Rgba; + var picture = new TerminalPicture("moving", 2, 2, + [new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(new byte[16], TimeSpan.FromMilliseconds(100))]); + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10); + string Render(TerminalFeatures features) => + RenderString(laid, new AnsiOutputOptions(Features: features) { Pictures = new Source(picture) }); + + await Assert.That(Render(TerminalFeatures.KittyGraphics)).DoesNotContain("a=f,").And.DoesNotContain("a=a,"); + var still = Convert.FromBase64String(Regex.Match(Render(TerminalFeatures.InlineImages), ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); + await Assert.That(still.Take(4)).IsEquivalentTo(new byte[] { 0x89, 0x50, 0x4E, 0x47 }); // a PNG, not a GIF + } + + /// A frame of no delay, or one too short to see, is shown for a tenth of a second, as browsers show it. + [Test] + public async Task AFrameOfNoDelayIsShownForATenthOfASecond() + { + var red = RedBlue(2, 2).Rgba; + var picture = new TerminalPicture("fast", 2, 2, + [new TerminalPictureFrame(red, TimeSpan.Zero), new TerminalPictureFrame(new byte[16], TimeSpan.FromMilliseconds(10)), + new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(20))]); + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10); + string Render(TerminalFeatures features) => + RenderString(laid, new AnsiOutputOptions(Features: features | TerminalFeatures.MovingPictures) { Pictures = new Source(picture) }); + + var kitty = Render(TerminalFeatures.KittyGraphics); + await Assert.That(Regex.Matches(kitty, @"a=f,[^;]*z=(\d+)").Select(m => m.Groups[1].Value)).IsEquivalentTo(new[] { "100", "20" }); + await Assert.That(kitty).Contains(",r=1,z=100,"); + var gif = GifReader.Read(Convert.FromBase64String(Regex.Match(Render(TerminalFeatures.InlineImages), ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value)); + await Assert.That(gif.Frames.Select(f => f.Centiseconds)).IsEquivalentTo(new[] { 10, 10, 2 }); + } + [Test] public async Task AMovingPictureIsItsFirstFrameWhereItCannotMove() { @@ -289,7 +323,7 @@ public async Task InlineImagesSendAMovingPictureAsALoopingGif() Array.Clear(blue, 8, 8); var picture = new TerminalPicture("moving", 2, 2, [new TerminalPictureFrame(red, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(blue, TimeSpan.FromMilliseconds(250))]); - var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) { Pictures = new Source(picture) }; + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures) { Pictures = new Source(picture) }; var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(4, 2)), options); var gif = Convert.FromBase64String(Regex.Match(output, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); @@ -324,7 +358,10 @@ byte[] Noise() var frames = new[] { Noise(), Noise(), Noise() }; var picture = new TerminalPicture("noise", 128, 128, [.. frames.Select(f => new TerminalPictureFrame(f, TimeSpan.FromMilliseconds(40)))]); - var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) { Pictures = new Source(picture), CellWidth = 16, CellHeight = 32 }; + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures) + { + Pictures = new Source(picture), CellWidth = 16, CellHeight = 32, + }; var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 20, new PictureCells(8, 4)), options); var decoded = GifReader.Read(Convert.FromBase64String(Regex.Match(output, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value)); @@ -335,6 +372,48 @@ byte[] Noise() await Assert.That(decoded.Frames[i].Rgba.SequenceEqual(frames[i])).IsTrue(); } + /// + /// A moving picture too big for one iTerm2 sequence goes in parts to a terminal that reads them, each part under + /// the limit; to one that does not, it is sent still. + /// + [Test] + public async Task AnInlineImageOverTheLimitIsSentInPartsOrStill() + { + var random = new Random(5); + TerminalPictureFrame Noise() + { + var rgba = new byte[512 * 512 * 4]; + random.NextBytes(rgba); + for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255; + return new TerminalPictureFrame(rgba, TimeSpan.FromMilliseconds(50)); + } + + var picture = new TerminalPicture("big", 512, 512, [Noise(), Noise(), Noise(), Noise()]); + var laid = Laid(new Figure(Cat, MarkupText.Empty), 20, new PictureCells(8, 4)); + string Render(TerminalProfile terminal) => RenderString(laid, + AnsiOutputOptions.For(terminal, TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures) with + { + Pictures = new Source(picture), + CellWidth = 64, + CellHeight = 128, + }); + + var parts = Render(TerminalProfile.ITerm2); + var sequences = Regex.Matches(parts, $"{Esc}]1337;([^{Bel}]*){Bel}").Select(m => m.Groups[1].Value).ToArray(); + await Assert.That(sequences[0]).StartsWith("MultipartFile=inline=1;size="); + await Assert.That(sequences[^1]).IsEqualTo("FileEnd"); + await Assert.That(sequences.Length).IsGreaterThan(3); + await Assert.That(sequences.All(s => s.Length + 8 <= TerminalProfile.ITerm2.InlineImageLimit)).IsTrue(); + var gif = Convert.FromBase64String(string.Concat(sequences[1..^1].Select(s => s["FilePart=".Length..]))); + await Assert.That(sequences[0]).Contains($"size={gif.Length};"); + await Assert.That(GifReader.Read(gif).Frames.Count).IsEqualTo(4); + + var still = Render(TerminalProfile.Konsole); + await Assert.That(still).DoesNotContain("MultipartFile"); + var png = Convert.FromBase64String(Regex.Match(still, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); + await Assert.That(png.Take(4)).IsEquivalentTo(new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + } + private static byte Centre(Random random) => (byte)(random.Next(32) * 8 + 4); /// diff --git a/MarkupString.Tests/Ansi/TerminalProfileTests.cs b/MarkupString.Tests/Ansi/TerminalProfileTests.cs new file mode 100644 index 0000000..2495568 --- /dev/null +++ b/MarkupString.Tests/Ansi/TerminalProfileTests.cs @@ -0,0 +1,42 @@ +using MarkupString.Ansi; + +/// The terminals this package knows by name, and what a player's choice lets each be sent. +public class TerminalProfileTests +{ + [Test] + [Arguments("kitty(0.35.2)", "kitty")] + [Arguments("xterm-kitty", "kitty")] + [Arguments("WezTerm 20240203-110809-5046fc22", "wezterm")] + [Arguments("iTerm2 3.5.0", "iterm2")] + [Arguments("ghostty 1.1.0", "ghostty")] + [Arguments("XTerm(390)", "xterm")] + [Arguments("foot(1.16.2)", "foot")] + public async Task ATerminalIsKnownByWhatItReports(string reported, string id) => + await Assert.That(TerminalProfile.Identify(reported)?.Id).IsEqualTo(id); + + [Test] + [Arguments("xterm-256color")] + [Arguments("MUDLET")] + [Arguments("")] + [Arguments(null)] + public async Task WhatNamesNoTerminalIsNone(string? reported) => + await Assert.That(TerminalProfile.Identify(reported)).IsNull(); + + [Test] + public async Task APlayerNamesATerminalByItsId() + { + await Assert.That(TerminalProfile.Find("Windows-Terminal")).IsSameReferenceAs(TerminalProfile.WindowsTerminal); + await Assert.That(TerminalProfile.Find("nonesuch")).IsNull(); + await Assert.That(TerminalProfile.Known.Select(p => p.Id).Distinct().Count()).IsEqualTo(TerminalProfile.Known.Count); + } + + [Test] + public async Task ATerminalIsSentOnlyWhatThePlayerTurnedOnOfWhatItCanDo() + { + var options = AnsiOutputOptions.For(TerminalProfile.Kitty, TerminalFeatures.KittyGraphics | TerminalFeatures.Sixel); + + await Assert.That(options.Features).IsEqualTo(TerminalFeatures.KittyGraphics); + await Assert.That(options.Terminal).IsSameReferenceAs(TerminalProfile.Kitty); + await Assert.That(AnsiOutputOptions.For(TerminalProfile.Kitty, TerminalFeatures.None).Features).IsEqualTo(TerminalFeatures.None); + } +} From 139a355a6184c2e404fc4746682ea0bd768159b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:21:04 +0000 Subject: [PATCH 3/4] Terminal profiles from each terminal's own sources; docs Rio, mlterm and tmux added; Ghostty, Konsole, mintty and VS Code show a GIF still or cannot animate Kitty frames in a release, so they are listed without MovingPictures; iTerm2 is drawn with its own protocol. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP --- CHANGELOG.md | 14 ++++ MarkupString.Ansi/PublicAPI.Unshipped.txt | 3 + MarkupString.Ansi/README.md | 42 +++++++++- MarkupString.Ansi/TerminalProfile.cs | 80 ++++++++++++++----- .../Ansi/TerminalFeatureTests.cs | 2 +- .../Ansi/TerminalProfileTests.cs | 3 + 6 files changed, 118 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d487f49..c417816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,20 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). RGB when the picture is opaque, filtered Up, at zlib level 2; sixel reads each band once and writes a colour only over the columns it reaches. +- **Moving pictures.** A `TerminalPicture` made from `TerminalPictureFrame`s plays for a client with + `MovingPictures` as well as a way of drawing it: Kitty is sent each frame (`a=f,X=1`) and starts the + loop itself (`a=a,s=3,v=1`), and iTerm2 is sent a looping GIF (one global palette by median cut, no + dithering, so nothing shimmers between frames). Everywhere else, and without `MovingPictures`, it is + its first frame. A frame of 10 ms or less is shown for 100 ms, as browsers do. +- **Terminals by name.** `TerminalProfile` lists the terminals this package knows (kitty, Ghostty, + WezTerm, iTerm2, Konsole, foot, xterm, Windows Terminal, mintty, VS Code, Contour, Rio, mlterm, + Alacritty, VTE and tmux): what each can be sent, read from its own source and release notes, and how. + `Identify` reads one from a terminal type or an XTVERSION reply, `Find` from the id a player gives. + `AnsiOutputOptions.For(terminal, chosen)` sends a terminal only what the player turned on of what it + can do, and `AnsiOutputOptions.Terminal` carries how it wants it: an iTerm2 inline image longer than + the terminal takes in one sequence (a mebibyte, for iTerm2) is sent in parts where it reads them + (`MultipartFile`), and a moving one is sent still where it does not. + ## 2.11.2 — 2026-10-07 ### Fixed diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 4bd6195..64922c0 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -91,6 +91,9 @@ static MarkupString.Ansi.TerminalProfile.Kitty.get -> MarkupString.Ansi.Terminal static MarkupString.Ansi.TerminalProfile.Known.get -> System.Collections.Generic.IReadOnlyList! static MarkupString.Ansi.TerminalProfile.Konsole.get -> MarkupString.Ansi.TerminalProfile! static MarkupString.Ansi.TerminalProfile.Mintty.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Mlterm.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Rio.get -> MarkupString.Ansi.TerminalProfile! +static MarkupString.Ansi.TerminalProfile.Tmux.get -> MarkupString.Ansi.TerminalProfile! static MarkupString.Ansi.TerminalProfile.VsCode.get -> MarkupString.Ansi.TerminalProfile! static MarkupString.Ansi.TerminalProfile.Vte.get -> MarkupString.Ansi.TerminalProfile! static MarkupString.Ansi.TerminalProfile.WezTerm.get -> MarkupString.Ansi.TerminalProfile! diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index bb59da3..1002efe 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -70,6 +70,7 @@ fully. `TerminalFeatures` says what its terminal reads beyond colour: | `InlineImages` | a picture as an iTerm2 inline image (`ESC ] 1337 ; File=…`) | | `Sixel` | a picture as sixel graphics | | `BlockArt` | a picture as coloured `▀`/`▄` half blocks, plain text any UTF-8 colour terminal shows | +| `MovingPictures` | a moving picture played, through Kitty frames or an iTerm2 GIF, rather than its first frame | Pictures are drawn into the cells a `Figure` reserves when it is laid out for such a reader (`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with @@ -83,9 +84,10 @@ neither fetches nor decodes a file. Without the pixels, or without the feature, erased like it. `MarkTransmitted` is how the source says whether the terminal already has it. A moving picture (a `TerminalPicture` made from `TerminalPictureFrame`s) sends its later frames after the first (`a=f,X=1`, each with its duration as `z`), sets the first frame's duration, and - starts it looping (`a=a,s=3,v=1`); the terminal plays it with nothing more sent. Every other way - of drawing shows its first frame. -- **iTerm2 and sixel** are pixels over the screen. On the picture's first row the cursor makes room + starts it looping (`a=a,s=3,v=1`); the terminal plays it with nothing more sent. Only a client with + `MovingPictures` is sent the frames. +- **iTerm2 and sixel** are pixels over the screen. A moving picture is sent to iTerm2, given + `MovingPictures`, as a looping GIF. On the picture's first row the cursor makes room below (`ESC D` per row), comes back up, and draws the picture between `ESC 7` and `ESC 8`; every row then steps over its cells with `CSI n C`, so no text is written over it. Sixel is the cells' size in pixels (`CellWidth` × `CellHeight`, 10 × 20 unless the host knows better; 0, which a terminal @@ -100,6 +102,40 @@ other connection shown it at that size is sent a copy. So a host should hand out None of these is sent on a guess: a MUD client that is not a terminal emulator may print them. +### Terminals by name + +`TerminalProfile` names the terminals this package knows and what each can be sent. A host identifies +the client's terminal from what it reports (`TerminalProfile.Identify("kitty(0.35.2)")`, a telnet +terminal type or an XTVERSION reply) or from the player (`TerminalProfile.Find("wezterm")`), and sends it +only what the player turned on: + +```csharp +var options = AnsiOutputOptions.For(TerminalProfile.WezTerm, chosenByPlayer) with { Pictures = source }; +``` + +| Terminal | Links | Kitty (placeholders) | iTerm2 images | Sixel | Moving pictures | +|---|---|---|---|---|---| +| kitty | yes | yes | | | Kitty frames | +| Ghostty | yes | yes | | | | +| WezTerm | yes | | yes | yes | GIF | +| iTerm2 | yes | | yes, in parts past 1 MiB | yes | GIF | +| Konsole | yes | | yes | yes | | +| foot | yes | | | yes | | +| xterm | | | | yes (`-ti vt340`) | | +| Windows Terminal | yes | | | yes | | +| mintty | yes | | yes | yes | | +| VS Code | yes | | yes (with images on) | yes | | +| Contour | yes | | | yes | | +| Rio | yes | yes | yes | yes | | +| mlterm | | | yes | yes | | +| Alacritty, VTE, tmux | yes | | | | | + +Every one draws half blocks. A cell is empty where the terminal lacks the feature or it could not be +confirmed: several terminals read Kitty graphics without the Unicode placeholders this package writes +(WezTerm, Konsole, Contour, VS Code), Ghostty does not yet animate them in a release, and iTerm2's own +Kitty support is unannounced, so it is drawn for with its own protocol. Windows Terminal and Alacritty do +not answer XTVERSION, so only a player can name them. + ## Styling the HTML output The HTML-family emitters write `ms-*` classes for the attributes with a fixed rendering (bold, diff --git a/MarkupString.Ansi/TerminalProfile.cs b/MarkupString.Ansi/TerminalProfile.cs index 4f02212..dadcfe2 100644 --- a/MarkupString.Ansi/TerminalProfile.cs +++ b/MarkupString.Ansi/TerminalProfile.cs @@ -37,21 +37,25 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe /// What it is sent of , the features the player turned on. public TerminalFeatures Allow(TerminalFeatures chosen) => Features & chosen; - /// kitty: Kitty graphics with Unicode placeholders, and their animation. + // What each terminal is listed with was read from its own source and release notes in October 2026; where a + // feature came in a later release, the release is named. A feature left out is one it lacks or one that could + // not be confirmed. + + /// kitty: Kitty graphics with Unicode placeholders (0.28), and their animation. public static TerminalProfile Kitty { get; } = new("kitty", "kitty", TerminalFeatures.Hyperlinks | TerminalFeatures.KittyGraphics | TerminalFeatures.MovingPictures | TerminalFeatures.BlockArt) { Reports = ["kitty", "xterm-kitty"], }; - /// Ghostty: Kitty graphics with Unicode placeholders. + /// Ghostty: Kitty graphics with Unicode placeholders. No released version animates them (1.3.1 refuses). public static TerminalProfile Ghostty { get; } = new("ghostty", "Ghostty", TerminalFeatures.Hyperlinks | TerminalFeatures.KittyGraphics | TerminalFeatures.BlockArt) { Reports = ["ghostty", "xterm-ghostty"], }; - /// WezTerm: iTerm2 inline images, which play a GIF, and sixel. + /// WezTerm: iTerm2 inline images, which play a GIF, and sixel. Its Kitty graphics have no placeholders. public static TerminalProfile WezTerm { get; } = new("wezterm", "WezTerm", TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) @@ -60,23 +64,27 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe }; /// - /// iTerm2: its own inline images, which play a GIF, one sequence at most a mebibyte, and since 3.5 sent in parts. + /// iTerm2: its own inline images, which play a GIF, one sequence at most a mebibyte, sent in parts since 3.5; + /// and sixel (3.3). It also reads Kitty graphics now, but does not animate them and its release notes do not + /// announce them, so it is drawn for with its own protocol. /// public static TerminalProfile ITerm2 { get; } = new("iterm2", "iTerm2", TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.MovingPictures | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { - Reports = ["iterm2", "iterm.app"], + Reports = ["iterm2"], InlineImageLimit = 1_048_576, MultipartInlineImages = true, }; - /// Konsole: iTerm2 inline images and sixel. + /// + /// Konsole: iTerm2 inline images (22.04), which show a GIF still, and sixel (22.04). Links are off until the + /// player allows them in its profile. Its Kitty graphics have no placeholders. + /// public static TerminalProfile Konsole { get; } = new("konsole", "Konsole", TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { Reports = ["konsole"], - InlineImageLimit = 1_048_576, }; /// foot: sixel. @@ -86,43 +94,58 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe Reports = ["foot", "foot-extra"], }; - /// xterm: sixel when it is started as a VT340 (-ti vt340). + /// xterm: sixel, when it emulates a terminal that had it (-ti vt340). No links. public static TerminalProfile XTerm { get; } = new("xterm", "xterm", TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { Reports = ["xterm"], }; - /// Windows Terminal: sixel since 1.22. + /// Windows Terminal: sixel (1.22). It does not say what it is, so only a player can name it. public static TerminalProfile WindowsTerminal { get; } = new("windows-terminal", "Windows Terminal", - TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) - { - Reports = ["ms-terminal"], - }; + TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt); - /// mintty: iTerm2 inline images and sixel. + /// mintty: iTerm2 inline images (3.1), up to its 4,444,444-byte image limit by default, and sixel. public static TerminalProfile Mintty { get; } = new("mintty", "mintty", TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { Reports = ["mintty"], - InlineImageLimit = 1_048_576, + InlineImageLimit = 4_444_444, }; - /// Visual Studio Code's terminal: iTerm2 inline images and sixel, once its image setting is on. + /// + /// Visual Studio Code's terminal (xterm.js): iTerm2 inline images, which show a GIF still, and sixel, once the + /// player turns on its terminal.integrated.enableImages setting. + /// public static TerminalProfile VsCode { get; } = new("vscode", "Visual Studio Code", TerminalFeatures.Hyperlinks | TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { - Reports = ["vscode"], - InlineImageLimit = 1_048_576, + Reports = ["xterm.js"], + InlineImageLimit = 32 * 1_048_576, }; - /// Contour: sixel. + /// Contour: sixel. Its Kitty graphics have no placeholders. public static TerminalProfile Contour { get; } = new("contour", "Contour", TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) { Reports = ["contour"], }; + /// Rio: Kitty graphics with Unicode placeholders (0.3), iTerm2 inline images shown still, and sixel. + public static TerminalProfile Rio { get; } = new("rio", "Rio", + TerminalFeatures.Hyperlinks | TerminalFeatures.KittyGraphics | TerminalFeatures.InlineImages | TerminalFeatures.Sixel + | TerminalFeatures.BlockArt) + { + Reports = ["rio"], + }; + + /// mlterm: iTerm2 inline images and sixel. No links. + public static TerminalProfile Mlterm { get; } = new("mlterm", "mlterm", + TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["mlterm"], + }; + /// Alacritty: links, and no pictures but half blocks. public static TerminalProfile Alacritty { get; } = new("alacritty", "Alacritty", TerminalFeatures.Hyperlinks | TerminalFeatures.BlockArt) @@ -130,16 +153,29 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe Reports = ["alacritty"], }; - /// A VTE terminal (GNOME Terminal, Tilix, Terminator and others): links, and half blocks. + /// + /// A VTE terminal (GNOME Terminal, Tilix, Terminator and others): links where the application allows them, and + /// half blocks. Its sixel is off unless both the library and the application were built for it. + /// public static TerminalProfile Vte { get; } = new("vte", "GNOME Terminal (VTE)", TerminalFeatures.Hyperlinks | TerminalFeatures.BlockArt) { Reports = ["vte"], }; + /// + /// tmux, which stands between the client and its terminal: links (3.4) and half blocks. A picture protocol has + /// to be passed through it, which this package does not write. + /// + public static TerminalProfile Tmux { get; } = new("tmux", "tmux", + TerminalFeatures.Hyperlinks | TerminalFeatures.BlockArt) + { + Reports = ["tmux"], + }; + /// Every terminal this package knows. public static IReadOnlyList Known { get; } = - [Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Alacritty, Vte]; + [Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Rio, Mlterm, Alacritty, Vte, Tmux]; /// The known terminal a player calls , compared without case, or null. public static TerminalProfile? Find(string? id) => @@ -147,7 +183,7 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe /// /// The known terminal that reported (a terminal type, or an XTVERSION reply such - /// as kitty(0.35.2) or WezTerm 20240203-110809-5046fc22), or null. Only the name is read, up to + /// as kitty(0.35.2), WezTerm 20240203-110809-5046fc22 or VTE(8000)), or null. Only the name is read, up to /// its first space or parenthesis, and it must be the whole of one of : the /// xterm-256color nearly every client sends names no terminal. /// diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index 769059c..f74705f 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -408,7 +408,7 @@ string Render(TerminalProfile terminal) => RenderString(laid, await Assert.That(sequences[0]).Contains($"size={gif.Length};"); await Assert.That(GifReader.Read(gif).Frames.Count).IsEqualTo(4); - var still = Render(TerminalProfile.Konsole); + var still = Render(TerminalProfile.ITerm2 with { MultipartInlineImages = false }); await Assert.That(still).DoesNotContain("MultipartFile"); var png = Convert.FromBase64String(Regex.Match(still, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); await Assert.That(png.Take(4)).IsEquivalentTo(new byte[] { 0x89, 0x50, 0x4E, 0x47 }); diff --git a/MarkupString.Tests/Ansi/TerminalProfileTests.cs b/MarkupString.Tests/Ansi/TerminalProfileTests.cs index 2495568..49c0fde 100644 --- a/MarkupString.Tests/Ansi/TerminalProfileTests.cs +++ b/MarkupString.Tests/Ansi/TerminalProfileTests.cs @@ -11,6 +11,9 @@ public class TerminalProfileTests [Arguments("ghostty 1.1.0", "ghostty")] [Arguments("XTerm(390)", "xterm")] [Arguments("foot(1.16.2)", "foot")] + [Arguments("VTE(8000)", "vte")] + [Arguments("tmux 3.5a", "tmux")] + [Arguments("Konsole 26.08.1", "konsole")] public async Task ATerminalIsKnownByWhatItReports(string reported, string id) => await Assert.That(TerminalProfile.Identify(reported)?.Id).IsEqualTo(id); From 4b64e521851140498f64cc28a7fcadd2e7e05ee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:26:13 +0000 Subject: [PATCH 4/4] A multipart limit too small for a part's framing sends the picture still Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01448U5MvpKkRkBjwzkkpPFP --- MarkupString.Ansi/Emitters/TerminalPictureWriter.cs | 1 + MarkupString.Tests/Ansi/TerminalFeatureTests.cs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs index b80ab6c..8743525 100644 --- a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -305,6 +305,7 @@ string Arguments(byte[] file) => string.Create(CultureInfo.InvariantCulture, // Each part is its own sequence, under the limit too; base64 in whole quanta of four. var part = (key.Limit - 32) / 4 * 4; + if (part <= 0) return null; var text = new StringBuilder(payload.Length + payload.Length / part * 32 + 128); text.Append(Esc).Append("]1337;MultipartFile=").Append(arguments).Append('\a'); for (var offset = 0; offset < payload.Length; offset += part) diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index f74705f..eff5244 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -408,6 +408,9 @@ string Render(TerminalProfile terminal) => RenderString(laid, await Assert.That(sequences[0]).Contains($"size={gif.Length};"); await Assert.That(GifReader.Read(gif).Frames.Count).IsEqualTo(4); + // A limit too small to hold a part's own framing is no reason to fail: the picture is sent still. + await Assert.That(Render(TerminalProfile.ITerm2 with { InlineImageLimit = 10 })).DoesNotContain("MultipartFile"); + var still = Render(TerminalProfile.ITerm2 with { MultipartInlineImages = false }); await Assert.That(still).DoesNotContain("MultipartFile"); var png = Convert.FromBase64String(Regex.Match(still, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value);