Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions MarkupString.Ansi/Emitters/TerminalPictureWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,60 @@ private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture
/// so a connection shown the picture after the first is sent a copy.
/// </summary>
/// <remarks>
/// <para>
/// Sent as PNG (<c>f=100</c>) rather than zlib-compressed raw pixels (<c>f=32,o=z</c>): the PNG's row
/// filtering and dropped alpha make it about half the size for the same compression time, and every
/// terminal that speaks the protocol decodes PNG.
/// </para>
/// <para>
/// A moving picture's first frame is the image; each later frame follows as a whole frame written over its
/// base (<c>a=f,X=1</c>) with its own duration (<c>z</c>), then the first frame's duration is set and the
/// animation started looping (<c>a=a,s=3,v=1</c>). The terminal plays it from then on; nothing more is sent.
/// </para>
/// </remarks>
private static string EncodeKitty(TerminalPicture picture, KittyKey key)
{
var (width, height) = PictureScaler.FitWithin(picture.Width, picture.Height,
key.Columns * key.CellWidth, key.Rows * key.CellHeight, upscale: false);
var pixels = PictureScaler.Scale(picture.Rgba.Span, picture.Width, picture.Height, width, height);
var text = new StringBuilder();
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)
{
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"),
"a=f,");
}

text.Append(CultureInfo.InvariantCulture,
$"{Esc}_Ga=a,i={key.Id},r=1,z={KittyGap(picture.Frames[0].Duration)},q=2{StringTerminator}");
text.Append(CultureInfo.InvariantCulture, $"{Esc}_Ga=a,i={key.Id},s=3,v=1,q=2{StringTerminator}");
}

return text.ToString();
}

/// <summary>
/// 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.
/// </summary>
private static int KittyGap(TimeSpan duration) => (int)Math.Clamp(duration.TotalMilliseconds, 1, int.MaxValue);

/// <summary>
/// <paramref name="rgba"/> scaled to <paramref name="width"/> 脳 <paramref name="height"/>, as a PNG in base64
/// chunks of at most <see cref="KittyChunk"/>: the first carrying <paramref name="first"/>, the rest
/// <paramref name="rest"/> (which a frame needs, <c>a=f,</c>) and each whether more follow.
/// </summary>
private static void AppendKittyChunks(StringBuilder text, ReadOnlySpan<byte> rgba, TerminalPicture picture, int width, int height,
string first, string rest)
{
var pixels = PictureScaler.Scale(rgba, picture.Width, picture.Height, width, height);
var payload = Convert.ToBase64String(PngWriter.Encode(pixels, width, height));
var chunks = (payload.Length + KittyChunk - 1) / KittyChunk;
text.EnsureCapacity(text.Length + payload.Length + 96 + payload.Length / KittyChunk * 20);

var text = new StringBuilder(payload.Length + 96 + chunks * 16);
var offset = 0;
do
{
Expand All @@ -177,20 +218,17 @@ private static string EncodeKitty(TerminalPicture picture, KittyKey key)
text.Append(Esc + "_G");
if (offset == 0)
{
text.Append(CultureInfo.InvariantCulture,
$"a=T,U=1,i={key.Id},f=100,c={key.Columns},r={key.Rows},q=2,m={more}");
text.Append(first).Append(",m=").Append(more);
}
else
{
text.Append("m=").Append(more).Append(",q=2");
text.Append(rest).Append("m=").Append(more).Append(",q=2");
}

text.Append(';').Append(payload, offset, length).Append(StringTerminator);
offset += length;
}
while (offset < payload.Length);

return text.ToString();
}

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions MarkupString.Ansi/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,30 @@ MarkupString.Ansi.TerminalFeatures.None = 0 -> MarkupString.Ansi.TerminalFeature
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
MarkupString.Ansi.TerminalPicture
MarkupString.Ansi.TerminalPicture.Frames.get -> System.Collections.Generic.IReadOnlyList<MarkupString.Ansi.TerminalPictureFrame>!
MarkupString.Ansi.TerminalPicture.Height.get -> int
MarkupString.Ansi.TerminalPicture.Key.get -> string!
MarkupString.Ansi.TerminalPicture.Rgba.get -> System.ReadOnlyMemory<byte>
MarkupString.Ansi.TerminalPicture.TerminalPicture(string! key, int width, int height, System.Collections.Generic.IReadOnlyList<MarkupString.Ansi.TerminalPictureFrame>! frames) -> void
MarkupString.Ansi.TerminalPicture.TerminalPicture(string! key, int width, int height, System.ReadOnlyMemory<byte> rgba) -> void
MarkupString.Ansi.TerminalPicture.Width.get -> int
MarkupString.Ansi.TerminalPictureFrame
MarkupString.Ansi.TerminalPictureFrame.Deconstruct(out System.ReadOnlyMemory<byte> Rgba, out System.TimeSpan Duration) -> void
MarkupString.Ansi.TerminalPictureFrame.Duration.get -> System.TimeSpan
MarkupString.Ansi.TerminalPictureFrame.Duration.init -> void
MarkupString.Ansi.TerminalPictureFrame.Equals(MarkupString.Ansi.TerminalPictureFrame other) -> bool
MarkupString.Ansi.TerminalPictureFrame.Rgba.get -> System.ReadOnlyMemory<byte>
MarkupString.Ansi.TerminalPictureFrame.Rgba.init -> void
MarkupString.Ansi.TerminalPictureFrame.TerminalPictureFrame() -> void
MarkupString.Ansi.TerminalPictureFrame.TerminalPictureFrame(System.ReadOnlyMemory<byte> Rgba, System.TimeSpan Duration) -> 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
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
4 changes: 4 additions & 0 deletions MarkupString.Ansi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ neither fetches nor decodes a file. Without the pixels, or without the feature,
nothing comes back as input) and writes each cell as `U+10EEEE` with row and column
diacritics, the image id as a truecolor foreground. Placeholders are text: they wrap, scroll and are
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
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
Expand Down
45 changes: 42 additions & 3 deletions MarkupString.Ansi/TerminalPicture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,48 @@ public sealed class TerminalPicture
/// <exception cref="ArgumentException"><paramref name="key"/> is empty, or <paramref name="rgba"/> is not the size the dimensions give.</exception>
/// <exception cref="ArgumentOutOfRangeException">A dimension is not positive.</exception>
public TerminalPicture(string key, int width, int height, ReadOnlyMemory<byte> rgba)
: this(key, width, height, rgba, [])
{
}

/// <summary>
/// Creates a moving picture from <paramref name="frames"/>, each a whole frame as it is shown. The first
/// frame is the picture's <see cref="Rgba"/>, which is what a terminal that cannot animate draws.
/// </summary>
/// <param name="key">What identifies these frames, such as a hash of the file.</param>
/// <param name="width">Its width in pixels.</param>
/// <param name="height">Its height in pixels.</param>
/// <param name="frames">The frames in order, each <paramref name="width"/> 脳 <paramref name="height"/> 脳 4 bytes; at least one.</param>
/// <exception cref="ArgumentException"><paramref name="key"/> is empty, there are no frames, or a frame is not the size the dimensions give.</exception>
/// <exception cref="ArgumentOutOfRangeException">A dimension is not positive.</exception>
public TerminalPicture(string key, int width, int height, IReadOnlyList<TerminalPictureFrame> frames)
: this(key, width, height, FirstOf(frames), frames.Count > 1 ? frames : [])
{
}

private TerminalPicture(string key, int width, int height, ReadOnlyMemory<byte> rgba, IReadOnlyList<TerminalPictureFrame> frames)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
if ((long)width * height * 4 != rgba.Length)
throw new ArgumentException($"A {width}x{height} picture is {(long)width * height * 4} bytes of RGBA, not {rgba.Length}.", nameof(rgba));
foreach (var pixels in frames.Select(frame => frame.Rgba).Prepend(rgba))
{
if ((long)width * height * 4 != pixels.Length)
throw new ArgumentException($"A {width}x{height} picture is {(long)width * height * 4} bytes of RGBA, not {pixels.Length}.", nameof(rgba));
}

Key = key;
Width = width;
Height = height;
Rgba = rgba;
Frames = frames;
}

private static ReadOnlyMemory<byte> FirstOf(IReadOnlyList<TerminalPictureFrame> frames)
{
ArgumentNullException.ThrowIfNull(frames);
if (frames.Count == 0) throw new ArgumentException("A picture has at least one frame.", nameof(frames));
return frames[0].Rgba;
}

/// <summary>What identifies these pixels.</summary>
Expand All @@ -41,10 +72,18 @@ public TerminalPicture(string key, int width, int height, ReadOnlyMemory<byte> r
/// <summary>Its height in pixels.</summary>
public int Height { get; }

/// <summary>The pixels: RGBA, row by row from the top left.</summary>
/// <summary>The pixels: RGBA, row by row from the top left. For a moving picture, its first frame.</summary>
public ReadOnlyMemory<byte> Rgba { get; }

/// <summary>Every frame of a moving picture, the first included; empty for a still one.</summary>
public IReadOnlyList<TerminalPictureFrame> Frames { get; }
}

/// <summary>One frame of a moving picture: the whole picture as it is shown, and for how long.</summary>
/// <param name="Rgba">The pixels: RGBA, row by row from the top left.</param>
/// <param name="Duration">How long the frame is shown before the next.</param>
public readonly record struct TerminalPictureFrame(ReadOnlyMemory<byte> Rgba, TimeSpan Duration);

/// <summary>
/// Where a terminal render finds a picture's pixels, and what it knows about the one connection it is
/// rendering for. A host builds one per connection and hands it to <see cref="AnsiOutputOptions.Pictures"/>.
Expand Down
47 changes: 47 additions & 0 deletions MarkupString.Tests/Ansi/TerminalFeatureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,53 @@ public async Task KittyWithoutThePixelsSendsTheArt()
await Assert.That(output).DoesNotContain(Placeholder);
}

[Test]
public async Task KittySendsEachFrameOfAMovingPictureThenStartsIt()
{
var red = RedBlue(2, 2).Rgba;
var blue = red.ToArray().Reverse().ToArray();
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 laid = Laid(new Figure(Cat, MarkupText.Empty), 10);

var first = RenderString(laid, options);
var second = RenderString(laid, options);
var id = source.Sent.Single();

await Assert.That(Regex.Matches(first, "a=T,U=1").Count).IsEqualTo(1);
await Assert.That(first).Contains($"a=f,i={id},f=100,X=1,z=250,q=2,m=0;");
await Assert.That(first).Contains($"a=a,i={id},r=1,z=100,q=2");
await Assert.That(first.IndexOf($"a=a,i={id},s=3,v=1,q=2", StringComparison.Ordinal))
.IsGreaterThan(first.IndexOf("a=f,", StringComparison.Ordinal));
await Assert.That(second).DoesNotContain("a=f,").And.DoesNotContain("a=a,");
}

[Test]
public async Task AMovingPictureIsItsFirstFrameWhereItCannotMove()
{
var still = RedBlue(2, 2);
var moving = new TerminalPicture("moving", 2, 2,
[new TerminalPictureFrame(still.Rgba, TimeSpan.FromMilliseconds(100)), new TerminalPictureFrame(new byte[16], TimeSpan.FromMilliseconds(100))]);
var laid = Laid(new Figure(Cat, MarkupText.Empty), 10);
string Render(TerminalPicture picture) =>
RenderString(laid, new AnsiOutputOptions(Features: TerminalFeatures.BlockArt) { Pictures = new Source(picture) });

await Assert.That(Render(moving)).IsEqualTo(Render(still));
await Assert.That(moving.Rgba.ToArray()).IsEquivalentTo(still.Rgba.ToArray());
}

[Test]
public async Task AMovingPictureNeedsFramesOfItsOwnSize()
{
await Assert.That(() => new TerminalPicture("bad", 2, 2,
[new TerminalPictureFrame(new byte[16], TimeSpan.Zero), new TerminalPictureFrame(new byte[4], TimeSpan.Zero)]))
.Throws<ArgumentException>();
await Assert.That(() => new TerminalPicture("none", 2, 2, Array.Empty<TerminalPictureFrame>())).Throws<ArgumentException>();
await Assert.That(new TerminalPicture("one", 2, 2, [new TerminalPictureFrame(new byte[16], TimeSpan.Zero)]).Frames).IsEmpty();
}

[Test]
public async Task KittyTransmissionIsChunkedAtFourKilobytes()
{
Expand Down
Loading