diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf4183c..2043336 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.
+
### Fixed
- **A box round a picture fits it in HTML.** The `
` of a `Figure` sat on the text baseline,
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
new file mode 100644
index 0000000..5bdc042
--- /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 frame in frames)
+ {
+ var pixels = frame.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 frame in frames)
+ {
+ 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(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);
+ 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..8743525 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,16 +262,56 @@ 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, 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.
+ ///
+ ///
+ /// 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);
- var png = PngWriter.Encode(PictureScaler.Scale(picture.Rgba.Span, picture.Width, picture.Height, width, height), 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");
+ byte[] Scale(ReadOnlyMemory rgba) => PictureScaler.Scale(rgba.Span, picture.Width, picture.Height, width, height);
+ 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;
+ 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)
+ 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..64922c0 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,57 @@ 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.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!
+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/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/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..dadcfe2
--- /dev/null
+++ b/MarkupString.Ansi/TerminalProfile.cs
@@ -0,0 +1,198 @@
+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;
+
+ // 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. 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. Its Kitty graphics have no placeholders.
+ 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, 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"],
+ InlineImageLimit = 1_048_576,
+ MultipartInlineImages = true,
+ };
+
+ ///
+ /// 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"],
+ };
+
+ /// foot: sixel.
+ public static TerminalProfile Foot { get; } = new("foot", "foot",
+ TerminalFeatures.Hyperlinks | TerminalFeatures.Sixel | TerminalFeatures.BlockArt)
+ {
+ Reports = ["foot", "foot-extra"],
+ };
+
+ /// 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 (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);
+
+ /// 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 = 4_444_444,
+ };
+
+ ///
+ /// 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 = ["xterm.js"],
+ InlineImageLimit = 32 * 1_048_576,
+ };
+
+ /// 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)
+ {
+ Reports = ["alacritty"],
+ };
+
+ ///
+ /// 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, Rio, Mlterm, Alacritty, Vte, Tmux];
+
+ /// 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), 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.
+ ///
+ 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 682dab9..eff5244 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()
{
@@ -280,6 +314,229 @@ 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 | 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);
+ 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 | 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));
+
+ 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();
+ }
+
+ ///
+ /// 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);
+
+ // 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);
+ 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);
+
+ ///
+ /// 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()
{
diff --git a/MarkupString.Tests/Ansi/TerminalProfileTests.cs b/MarkupString.Tests/Ansi/TerminalProfileTests.cs
new file mode 100644
index 0000000..49c0fde
--- /dev/null
+++ b/MarkupString.Tests/Ansi/TerminalProfileTests.cs
@@ -0,0 +1,45 @@
+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")]
+ [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);
+
+ [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);
+ }
+}