diff --git a/README.md b/README.md index b982e3e..33f5f3d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ painted them is not kept at all. | 2.x | 3.0 | | --- | --- | -| A Sixel appears in `line.Placements` and `line.Images` | It is painted into `line.Sixel`; `Placements` and `Images` hold Kitty pictures only | +| A Sixel appears in `line.Placements` and `line.Images` | It is painted into `line.Sixel`, a `SixelStrip` read with `CopyPixelsTo`; `Placements` and `Images` hold Kitty pictures only | | `PlacementKind.Sixel` runs | Never created. The value remains so code that compares against it still compiles | | `TryGetPlacementAt` / `TryGetImageAt` find Sixel | They find Kitty pictures; ask `line.Sixel?.HasPixelsAt(column)` | | A later Sixel splits the one under it | It paints over it: opaque pixels replace, unset pixels (background select 1) leave what was there | @@ -71,25 +71,33 @@ painted them is not kept at all. | Kitty deletes by position or z-index also removed Sixel runs | They no longer touch Sixel; `a=d,d=a` still clears everything | | `MaxImageBytes` counted pictures | It counts pictures and strips; scrollback strips go first, oldest line first, and what is on screen is never taken | | iTerm2 inline images were Sixel placements | They are painted into strips, as Sixel is | +| Sixel colour registers were private to each image (xterm's default) | They are shared between images, as on a VT340 and in Windows Terminal; `CSI ? 1070 h` asks for private registers, and RIS resets the shared ones | +| The raster attribute's aspect ratio (`"Pan;Pad`) was ignored | Each sixel row is Pan/Pad pixels tall (capped at 16); P1 is still ignored | `line.HasImages` is true for a line with a strip as well as one with Kitty runs. **What a renderer does now.** Per line: Kitty pictures behind the text (negative `ZIndex`), then the text, then the line's Sixel strip, then Kitty pictures in front — see [Images](#images) for the whole -row. The strip is one blit: +row. The strip is one blit, read out with `CopyPixelsTo`: ```csharp if (line.Sixel is { } strip) { - // BGRA8888, straight alpha, top row first: PaintedColumns cells of CellWidth x CellHeight pixels. - // Cache the upload against the strip and upload again when strip.Version changes. + // Up to the last painted cell, which is all the row shows. Cache the upload against the strip + // and upload again when strip.Version changes. var cols = Math.Min(strip.PaintedColumns, line.Length); - DrawImage(bitmapFor(strip), - source: (0, 0, cols * strip.CellWidth, strip.CellHeight), + var stride = cols * strip.CellWidth * 4; + var pixels = new byte[stride * strip.PixelHeight]; // or a pooled buffer + strip.CopyPixelsTo(pixels, stride, cols); // BGRA8888, straight alpha, top row first + DrawImage(BitmapFrom(pixels, cols * strip.CellWidth, strip.PixelHeight, stride), dest: (0, row * cellHeight, cols * cellWidth, cellHeight)); } ``` +A strip has no `Pixels` property to hand out its memory: it is stored so that copying a line -- which +a host capturing frames does many times a second -- stays cheap, and `CopyPixelsTo` gives back an +ordinary contiguous image. + A strip is painted at the cell size in force when its Sixels arrived, so after a font change it is stretched onto the new grid, as a picture is. A strip can be painted by the thread writing to the terminal while you read it: read `Version` before copying and again after, and treat a copy whose @@ -605,11 +613,13 @@ void DrawKittyRun(BufferLine line, LinePlacement p, int row) it lets the bitmap die when the image does, with no eviction list to maintain, and two placements of one picture share the single upload. For animation draw `image.CurrentPixels` and upload again when `image.FrameSerial` changes. -- A Sixel strip: cache against the `SixelStrip` and upload again when `strip.Version` changes. A - strip is painted by the thread writing to the terminal, so read `Version` before copying the - pixels and again after: a copy whose version moved may be torn, so draw it but do not keep it. - A line's copies (`Clone`, `CopyFrom`) share its strip until one of them is written, so a frame - captured for a renderer costs a reference rather than the pixels. +- A Sixel strip: cache against the `SixelStrip` and upload again when `strip.Version` changes, + copying the pixels out with `strip.CopyPixelsTo(destination, stride, columns)` -- into a pooled + buffer if you upload every frame, so the upload is not itself a large allocation per frame. A + strip is painted by the thread writing to the terminal, so read `Version` before copying and again + after: a copy whose version moved may be torn, so draw it but do not keep it. A line's copies + (`Clone`, `CopyFrom`) share its strip, and writing to a shared strip copies only the part the + write touches, so a frame captured for a renderer is cheap. Draws, not pixels, are what a busy screen costs. A program repainting small regions over and over leaves at most one strip per row however many Sixels it sends, and a Kitty client that tiles the diff --git a/src/XTerm.NET.Tests/ApiLifecycleTests.cs b/src/XTerm.NET.Tests/ApiLifecycleTests.cs index b68ff3e..3b724e5 100644 --- a/src/XTerm.NET.Tests/ApiLifecycleTests.cs +++ b/src/XTerm.NET.Tests/ApiLifecycleTests.cs @@ -63,7 +63,7 @@ public void Reset_restores_the_sixel_modes() terminal.Write($"{Esc}c"); Assert.False(terminal.SixelDisplayMode); - Assert.True(terminal.SixelPrivateColorRegisters); + Assert.False(terminal.SixelPrivateColorRegisters); } [Fact] diff --git a/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs b/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs index 3789bb1..bbf6067 100644 --- a/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs +++ b/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs @@ -118,11 +118,19 @@ public static (byte R, byte G, byte B, byte A)? SixelPixel(Terminal terminal, in return null; var strip = StripOn(terminal, screenRow)!; + var p = StripPixels(strip); var offset = y * strip.Stride + (col * strip.CellWidth + x) * TerminalImage.BytesPerPixel; - var p = strip.Pixels.Span; return (p[offset + 2], p[offset + 1], p[offset], p[offset + 3]); } + /// A strip's pixels laid out contiguously, BGRA, bytes a row. + public static byte[] StripPixels(SixelStrip strip) + { + var pixels = new byte[strip.Stride * strip.PixelHeight]; + strip.CopyPixelsTo(pixels, strip.Stride, strip.Columns); + return pixels; + } + /// How many cells of the buffer show Sixel pixels, clipped to each line's width. public static int VisibleSixelCells(Terminal terminal) { diff --git a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs index dbdc37d..4667482 100644 --- a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs +++ b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs @@ -351,12 +351,15 @@ public void Clearing_a_dropped_image_leaves_a_blank_rather_than_a_hole() [Fact] public void Scrollback_pixels_are_dropped_first_when_the_budget_is_exceeded() { - // Each painted line is a 20-cell strip of 2x3 BGRA cells: 480 bytes. Two pictures of four - // lines are 3840; 2500 holds one of them. - var terminal = Fresh(o => o.MaxImageBytes = 2500); + var terminal = Fresh(); WriteSixel(terminal); var oldest = terminal.Buffer.Lines[terminal.Buffer.YBase]!; + // Two pictures of four painted lines each are eight strips; a budget of five and a half + // holds one picture and part of the other. + var lineBytes = oldest.Sixel!.ByteCount; + terminal.Options.MaxImageBytes = (int)(lineBytes * 11 / 2); + // Scroll the first picture off the screen into the scrollback, then draw a second. terminal.Write($"{Esc}[10;1H" + string.Concat(Enumerable.Repeat("\r\n", 10))); terminal.Write($"{Esc}[1;1H"); @@ -536,7 +539,7 @@ public void A_copy_of_a_line_keeps_its_pixels_when_the_original_is_painted() WriteSixel(terminal, BlueTwoByFour); Assert.NotSame(line.Sixel, clone.Sixel); - var pixels = clone.Sixel!.Pixels.Span; + var pixels = ImageAssertions.StripPixels(clone.Sixel!); Assert.Equal((byte)255, pixels[2]); // still red: B, G, R, A Assert.Equal((byte)0, pixels[0]); } diff --git a/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs b/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs index aa9bd8f..f908623 100644 --- a/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs +++ b/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs @@ -237,6 +237,144 @@ public void An_image_that_grows_past_the_budget_while_drawing_is_discarded() Assert.Null(image); } + /// + /// A wide picture that grows downwards near the budget keeps what it has drawn. Growing for a + /// taller band doubled the width as well, overshot the budget, and the retry at "exactly the + /// size asked for" took the narrow width the new band needed -- narrower than the canvas, so + /// copying the old rows across ran off the end of the buffer. The exception escaped + /// Terminal.Write and took the host's read loop with it: VT DOOM froze on its first frame. + /// + [Fact] + public void A_wide_image_growing_downwards_near_the_budget_keeps_its_rows() + { + // 300 wide, then a 10-wide second band: doubling both ways asks 600x12 = 7200 pixels. + TerminalImage? image = null; + var exception = Record.Exception(() => + image = TryDecode("#0;2;100;0;0!300~-!10~", configure: o => o.MaxSixelPixels = 5000)); + + Assert.Null(exception); + Assert.NotNull(image); + Assert.Equal(300, image!.PixelWidth); + Assert.Equal(12, image.PixelHeight); + Assert.Equal(Red, Pixel(image, 299, 0)); + Assert.Equal(Red, Pixel(image, 9, 6)); + } + + /// + /// A picture that fits the budget is kept even when the canvas's doubling would not. Drawn in + /// pieces, the canvas doubles ahead of the picture -- 300 wide on a 512-wide canvas here -- and + /// the retry kept that whole capacity, failed the budget, and threw the picture away. VT DOOM at + /// 307x75 cells lost every frame that grew that way: data arriving, nothing on screen. + /// + [Fact] + public void A_picture_inside_the_budget_survives_the_canvas_doubling_past_it() + { + // Three runs make it 300 wide by doubling to 512; ten bands make it 60 tall. + var body = "#0;2;100;0;0!100~!100~!100~" + string.Concat(Enumerable.Repeat("-!300~", 9)); + var image = TryDecode(body, configure: o => o.MaxSixelPixels = 300 * 60 * 13 / 10); + + Assert.NotNull(image); + Assert.Equal(300, image!.PixelWidth); + Assert.Equal(60, image.PixelHeight); + Assert.Equal(Red, Pixel(image, 299, 59)); + } + + /// + /// The raster attribute's Pan;Pad is the pixel aspect ratio: each sixel row is Pan/Pad pixel + /// rows tall. VT DOOM scales its picture vertically this way (and horizontally with repeat + /// counts); ignoring it drew the game squashed to a fifth of its height. + /// + [Fact] + public void The_raster_aspect_ratio_makes_each_sixel_row_that_many_pixels_tall() + { + // 3:1, so one band of six sixel rows is eighteen pixels; '@' sets only the top sixel row. + var image = Decode("\"3;1#0;2;100;0;0!2@-!2~"); + + Assert.Equal(2, image.PixelWidth); + Assert.Equal(36, image.PixelHeight); + Assert.Equal(Red, Pixel(image, 0, 0)); + Assert.Equal(Red, Pixel(image, 0, 2)); // still the first sixel row + Assert.Equal((byte)0, Pixel(image, 0, 3).A); // the second sixel row was not set + Assert.Equal(Red, Pixel(image, 1, 18)); // the second band starts 18 rows down + Assert.Equal(Red, Pixel(image, 1, 35)); + } + + [Fact] + public void A_square_aspect_ratio_and_none_at_all_draw_the_same() + { + var square = Decode("\"1;1#0;2;100;0;0!3~-!3~"); + var none = Decode("#0;2;100;0;0!3~-!3~"); + + Assert.Equal(none.PixelHeight, square.PixelHeight); + Assert.Equal(12, square.PixelHeight); + Assert.True(square.Pixels.Span.SequenceEqual(none.Pixels.Span)); + } + + [Fact] + public void An_absurd_aspect_ratio_is_capped() + { + var image = Decode("\"1000;1#0;2;100;0;0~"); + + Assert.Equal(16 * 6, image.PixelHeight); + } + + /// + /// Colour registers are shared between images by default, as on a VT340 and in Windows + /// Terminal: a register one image defines is still defined for the next. VT DOOM sends its + /// palette only when it changes, and drew in a scatter of default colours on black without this. + /// + [Fact] + public void A_colour_defined_by_one_image_is_still_defined_for_the_next() + { + var terminal = Fresh(); + WriteSixel(terminal, "#200;2;0;100;0#200!2~"); + WriteSixel(terminal, "#200!2~"); + + Assert.Equal(Green, Pixel(Placed(terminal)!, 0, 0)); + } + + [Fact] + public void Private_registers_are_still_one_mode_away() + { + var terminal = Fresh(); + terminal.Write($"{Esc}[?1070h"); + WriteSixel(terminal, "#200;2;0;100;0#200!2~"); + WriteSixel(terminal, "#200!2~"); + + // A fresh palette for the second image: register 200 is back to its default. + Assert.NotEqual(Green, Pixel(Placed(terminal)!, 0, 0)); + } + + [Fact] + public void A_full_reset_returns_the_shared_registers_to_their_defaults() + { + var terminal = Fresh(); + WriteSixel(terminal, "#200;2;0;100;0#200!2~"); + terminal.Write($"{Esc}c"); + WriteSixel(terminal, "#200!2~"); + + Assert.NotEqual(Green, Pixel(Placed(terminal)!, 0, 0)); + } + + /// + /// The decoder reuses its canvas from one picture to the next, so a smaller, transparent picture + /// after a large opaque one must start from clear pixels rather than the last picture's. + /// + [Fact] + public void A_reused_canvas_does_not_show_the_previous_picture() + { + var terminal = Fresh(); + WriteSixel(terminal, "#0;2;100;0;0!40~-!40~-!40~", OpaqueBackground); + WriteSixel(terminal, "#1;2;0;100;0!2@"); + + var second = Placed(terminal)!; + Assert.Equal(2, second.PixelWidth); + Assert.Equal(6, second.PixelHeight); + Assert.Equal(Green, Pixel(second, 0, 0)); + Assert.Equal((byte)0, Pixel(second, 0, 1).A); // unset, so transparent -- not the red before it + Assert.Equal((byte)0, Pixel(second, 1, 5).A); + } + [Fact] public void An_abandoned_payload_produces_no_image() { diff --git a/src/XTerm.NET.Tests/RequestModeTests.cs b/src/XTerm.NET.Tests/RequestModeTests.cs index e67b7be..85cedb2 100644 --- a/src/XTerm.NET.Tests/RequestModeTests.cs +++ b/src/XTerm.NET.Tests/RequestModeTests.cs @@ -109,6 +109,7 @@ public void Reports_each_mode_as_set_after_setting_it_and_reset_after_resetting_ [InlineData(1006)] // SGR mouse encoding [InlineData(1016)] // SGR-Pixels mouse encoding [InlineData(1049)] // alternate buffer + [InlineData(1070)] // private Sixel colour registers: shared by default, as on a VT340 [InlineData(2004)] // bracketed paste [InlineData(2026)] // synchronized output public void Reports_an_untouched_mode_as_reset(int mode) @@ -138,13 +139,11 @@ public void Reports_grapheme_clustering_as_permanently_set() /// /// Some modes start on, and reporting them as reset would cost an application the feature: it - /// would turn wraparound on that was already on, show a cursor it meant to leave alone, and - /// switch Sixel colour registers to shared when they were already private. + /// would turn wraparound on that was already on, or show a cursor it meant to leave alone. /// [Theory] [InlineData(7)] // wraparound [InlineData(25)] // cursor visible - [InlineData(1070)] // private Sixel colour registers public void Reports_a_mode_that_starts_on_as_set(int mode) { var terminal = Fresh(); diff --git a/src/XTerm.NET/Graphics/SixelDecoder.cs b/src/XTerm.NET/Graphics/SixelDecoder.cs index 675f369..11b4aef 100644 --- a/src/XTerm.NET/Graphics/SixelDecoder.cs +++ b/src/XTerm.NET/Graphics/SixelDecoder.cs @@ -13,6 +13,50 @@ namespace XTerm.Graphics; /// are all things that happen, and the answer to each is a null image rather than an exception /// escaping into the parser. /// +/// +/// The decoder's working buffers, kept between images. +/// +/// +/// A Sixel picture is thrown away the moment its pixels are painted into the lines it lands on, +/// so the canvas it was decoded into can be the next picture's canvas. Allocating one per picture -- +/// and again each time it grew, and once more for the exactly sized copy -- put several +/// large-object-heap arrays per frame on a program that streams frames: VT DOOM at 1600x1000 drove +/// two dozen full garbage collections a second, every one of them stopping the UI thread, and the +/// game's keyboard input arrived in bursts. +/// Two buffers, because growing copies the old canvas into the new one. One per terminal, used +/// only from the thread writing to it. The image a decode returns points into one of them and is +/// valid until the next Sixel arrives -- which is longer than anything that reads it. +/// +internal sealed class SixelCanvas +{ + private byte[] _first = Array.Empty(); + private byte[] _second = Array.Empty(); + + /// + /// The size of the last picture decoded, which the next one starts its canvas at. A program + /// streaming frames sends the same size every time, so after the first it never grows. + /// + public int LastWidth { get; set; } + + /// + public int LastHeight { get; set; } + + /// A buffer of at least , never . Its contents are stale. + public byte[] Take(long bytes, byte[] inUse) + { + if (ReferenceEquals(_first, inUse)) + { + if (_second.LongLength < bytes) + _second = new byte[bytes]; + return _second; + } + + if (_first.LongLength < bytes) + _first = new byte[bytes]; + return _first; + } +} + internal sealed class SixelDecoder { /// Rows of pixels a single Sixel character covers. @@ -24,6 +68,12 @@ internal sealed class SixelDecoder /// private const int MaxRepeat = 1 << 20; + /// + /// Ceiling on the pixel aspect ratio. A VT340 offered up to 5:1 through P1; anything past this + /// is a payload asking for a picture made of very tall pixels, which is a way to spend memory. + /// + private const int MaxAspect = 16; + /// What the parser is in the middle of reading. private enum Mode { @@ -41,6 +91,7 @@ private enum Mode private readonly int _cellHeight; private readonly long _maxPixels; private readonly SixelPalette _palette; + private readonly SixelCanvas? _canvas; /// /// What an unset pixel becomes: transparent, or the terminal background. Background select 1 @@ -65,12 +116,23 @@ private enum Mode private int _rasterWidth; private int _rasterHeight; + /// + /// Pixel rows each sixel row covers: the raster attribute's pixel aspect ratio, Pan/Pad. One for + /// a square pixel, which is what nearly every encoder asks for; VT DOOM asks for its scale, and + /// scales horizontally with repeat counts to match. + /// + private int _aspect = 1; + + /// Pixel rows one band of six sixel rows covers. + private int BandPixels => BandHeight * _aspect; + private Mode _mode = Mode.Ground; private readonly int[] _params = new int[8]; private int _paramCount; private int _param; - /// Pixel aspect ratio. Superseded by the raster attribute and ignored. + /// Pixel aspect ratio. Ignored, as xterm ignores it; the raster attribute's + /// Pan;Pad states the aspect ratio when a picture wants one other than square. /// Background select. 1 leaves unset pixels transparent. /// Horizontal grid size. Unused by every implementation, including this one. /// Cell width in pixels, which decides how many columns the image covers. @@ -84,9 +146,11 @@ private enum Mode /// The colour registers to draw from. Pass a fresh one for private registers (mode 1070 set, /// the default) or a shared one to let images inherit each other's colours the way a VT340 did. /// + /// Buffers to decode into and reuse, or null to allocate. See . public SixelDecoder(int p1, int p2, int p3, int cellWidth, int cellHeight, int maxPixels, - uint backgroundBgra, SixelPalette palette) + uint backgroundBgra, SixelPalette palette, SixelCanvas? canvas = null) { + _canvas = canvas; _ = p1; _ = p3; _cellWidth = Math.Max(1, cellWidth); @@ -135,10 +199,24 @@ public void Put(ReadOnlySpan chunk) { exact = _pixels; } + else if (_canvas is not null) + { + // In place: the image is no wider and no taller than the canvas, so each row moves to + // an offset at or before its own, and copying top to bottom never overwrites a row it + // has yet to move. The image is the first width*height pixels of the buffer, which a + // TerminalImage is allowed to be. + int rowBytes = width * TerminalImage.BytesPerPixel; + for (int y = 0; y < height; y++) + { + System.Buffer.BlockCopy(_pixels, y * _capWidth * TerminalImage.BytesPerPixel, + _pixels, y * rowBytes, rowBytes); + } + exact = _pixels; + } else { exact = new byte[(long)width * height * TerminalImage.BytesPerPixel]; - Prefill(exact); + Prefill(exact, exact.LongLength); int copyBytes = Math.Min(width, _capWidth) * TerminalImage.BytesPerPixel; int copyRows = Math.Min(height, _capHeight); for (int y = 0; y < copyRows; y++) @@ -148,6 +226,12 @@ public void Put(ReadOnlySpan chunk) } } + if (_canvas is not null) + { + _canvas.LastWidth = width; + _canvas.LastHeight = height; + } + return new TerminalImage(exact, width, height, _cellWidth, _cellHeight); } @@ -194,8 +278,8 @@ private void Feed(char c) case '-': // graphics new line _x = 0; _repeat = 1; - if (_bandTop <= int.MaxValue - BandHeight) - _bandTop += BandHeight; + if (_bandTop <= int.MaxValue - BandPixels) + _bandTop += BandPixels; return; default: if (c >= '?' && c <= '~') @@ -248,6 +332,10 @@ private void CompleteMode() break; case Mode.Raster: + // "Pan;Pad;Ph;Pv. The aspect ratio is how many pixels tall a sixel row is for each + // pixel it is wide, rounded to a whole number of rows. + if (_paramCount >= 2 && _params[0] > 0 && _params[1] > 0) + _aspect = Math.Clamp((_params[0] + _params[1] / 2) / _params[1], 1, MaxAspect); if (_paramCount >= 4) ApplyRaster(_params[2], _params[3]); break; @@ -287,7 +375,7 @@ private void WriteSixel(char c) if (repeat <= 0) repeat = 1; - int bandBottom = _bandTop + BandHeight - 1; + int bandBottom = _bandTop + BandPixels - 1; if (bandBottom > _maxBandBottom) _maxBandBottom = bandBottom; @@ -317,18 +405,22 @@ private void WriteSixel(char c) if ((bits & (1 << row)) == 0) continue; - int y = _bandTop + row; - if (_rasterHeight > 0 && y >= _rasterHeight) - continue; - - int offset = (y * _capWidth + startX) * TerminalImage.BytesPerPixel; - for (int i = startX; i < endX; i++) + // A sixel row is _aspect pixel rows tall. + for (int k = 0; k < _aspect; k++) { - _pixels[offset] = (byte)_color; // B - _pixels[offset + 1] = (byte)(_color >> 8); // G - _pixels[offset + 2] = (byte)(_color >> 16); // R - _pixels[offset + 3] = (byte)(_color >> 24); // A - offset += TerminalImage.BytesPerPixel; + int y = _bandTop + row * _aspect + k; + if (_rasterHeight > 0 && y >= _rasterHeight) + continue; + + int offset = (y * _capWidth + startX) * TerminalImage.BytesPerPixel; + for (int i = startX; i < endX; i++) + { + _pixels[offset] = (byte)_color; // B + _pixels[offset + 1] = (byte)(_color >> 8); // G + _pixels[offset + 2] = (byte)(_color >> 16); // R + _pixels[offset + 3] = (byte)(_color >> 24); // A + offset += TerminalImage.BytesPerPixel; + } } } } @@ -348,15 +440,32 @@ private bool EnsureCapacity(int needWidth, int needHeight) // An image without raster attributes reveals its size only by being drawn, so the canvas // doubles rather than growing to fit -- otherwise a wide image re-strides its whole buffer - // once per column. - int width = _capWidth == 0 ? Math.Max(needWidth, 256) : Math.Max(needWidth, _capWidth * 2); - int height = _capHeight == 0 ? Math.Max(needHeight, BandHeight) : Math.Max(needHeight, _capHeight * 2); + // once per column. Only the dimension that is short doubles: doubling the width of a wide + // picture every time it grew another band taller ran it into the budget long before its + // pixels did. + // A first canvas starts at the last picture's size when there was one (see SixelCanvas): + // frames of one size then need no growing at all. + int width = _capWidth == 0 ? Math.Max(needWidth, Math.Max(256, _canvas?.LastWidth ?? 0)) + : needWidth > _capWidth ? Math.Max(needWidth, _capWidth * 2) : _capWidth; + int height = _capHeight == 0 ? Math.Max(needHeight, Math.Max(BandHeight, _canvas?.LastHeight ?? 0)) + : needHeight > _capHeight ? Math.Max(needHeight, _capHeight * 2) : _capHeight; if ((long)width * height > _maxPixels) { - // Try once at exactly the size asked for; doubling may have been what overshot. - width = needWidth; - height = needHeight; + // Try once at exactly the size asked for; doubling may have been what overshot. Never + // SMALLER than the canvas already is in either dimension: what is drawn is copied across + // row by row at the old width, and a narrower canvas -- the case where only the height + // was short, so needWidth is less than the width already allocated -- had that copy run + // off the end of the new buffer. The exception escaped Terminal.Write and took the + // host's read loop with it: a wide picture growing downwards (VT DOOM) froze the screen. + // + // Nor larger than what has actually been DRAWN, though: the canvas doubles, so its + // capacity runs ahead of the picture -- a 2,188-pixel-wide frame sits on a 4,096-wide + // canvas -- and keeping that whole capacity made the retry fail the budget too, for a + // picture well inside it. VT DOOM at 307x75 cells lost every frame that grew that way. + // Nothing lies past the drawn extent, so the copy below takes only that. + width = Math.Max(needWidth, Math.Min(_capWidth, _maxX)); + height = Math.Max(needHeight, Math.Min(_capHeight, _maxBandBottom + 1)); if ((long)width * height > _maxPixels) { _failed = true; @@ -364,16 +473,21 @@ private bool EnsureCapacity(int needWidth, int needHeight) } } - var next = new byte[(long)width * height * TerminalImage.BytesPerPixel]; - Prefill(next); + long bytes = (long)width * height * TerminalImage.BytesPerPixel; + var next = _canvas?.Take(bytes, _pixels) ?? new byte[bytes]; + Prefill(next, bytes); if (_capWidth > 0 && _capHeight > 0) { - int rowBytes = _capWidth * TerminalImage.BytesPerPixel; - for (int y = 0; y < _capHeight; y++) + // The overlap of the two canvases: the new one can be narrower or shorter than the old + // one's capacity (see the retry above), never than what was drawn on it. + int sourceStride = _capWidth * TerminalImage.BytesPerPixel; + int copyBytes = Math.Min(_capWidth, width) * TerminalImage.BytesPerPixel; + int copyRows = Math.Min(_capHeight, height); + for (int y = 0; y < copyRows; y++) { - Array.Copy(_pixels, (long)y * rowBytes, - next, (long)y * width * TerminalImage.BytesPerPixel, rowBytes); + Array.Copy(_pixels, (long)y * sourceStride, + next, (long)y * width * TerminalImage.BytesPerPixel, copyBytes); } } @@ -384,24 +498,30 @@ private bool EnsureCapacity(int needWidth, int needHeight) } /// - /// Paints a fresh buffer with the background. Skipped entirely when the background is - /// transparent, because a new array is already all zeroes. + /// Paints the first bytes of a buffer with the background, or clears + /// them for a transparent one. A reused canvas holds the last picture, so even the transparent + /// case has to write. /// - private void Prefill(byte[] buffer) + private void Prefill(byte[] buffer, long length) { if (_fill == 0) + { + Array.Clear(buffer, 0, (int)length); return; + } - byte b = (byte)_fill; - byte g = (byte)(_fill >> 8); - byte r = (byte)(_fill >> 16); - byte a = (byte)(_fill >> 24); - for (long i = 0; i < buffer.LongLength; i += TerminalImage.BytesPerPixel) + // _fill packs B, G, R, A from the low byte up, which is the order a little-endian uint + // lays them out in memory -- so a pixel is one uint store. + if (!BitConverter.IsLittleEndian) { - buffer[i] = b; - buffer[i + 1] = g; - buffer[i + 2] = r; - buffer[i + 3] = a; + byte b = (byte)_fill, g = (byte)(_fill >> 8), r = (byte)(_fill >> 16), a = (byte)(_fill >> 24); + for (long i = 0; i < length; i += TerminalImage.BytesPerPixel) + { + buffer[i] = b; buffer[i + 1] = g; buffer[i + 2] = r; buffer[i + 3] = a; + } + return; } + + System.Runtime.InteropServices.MemoryMarshal.Cast(buffer.AsSpan(0, (int)length)).Fill(_fill); } } diff --git a/src/XTerm.NET/Graphics/SixelStrip.cs b/src/XTerm.NET/Graphics/SixelStrip.cs index 41947dd..b868207 100644 --- a/src/XTerm.NET/Graphics/SixelStrip.cs +++ b/src/XTerm.NET/Graphics/SixelStrip.cs @@ -11,29 +11,40 @@ namespace XTerm.Graphics; /// slivers of each are still showing. A program that repaints small regions over and over (a cursor, /// a hover, a diffing framebuffer) costs one strip per line however many Sixels it sends, where /// keeping the pictures grew thousands of fragments per screen, every one a separate draw. -/// Layout. BGRA, straight alpha, top row first, exactly like -/// : cells of pixels -/// across and pixels down. A pixel nothing has painted is fully transparent, -/// so the cell beneath shows through it. is the widest anything has painted and -/// is never clipped to the line, so a terminal widened later shows more rather than having lost it -- -/// the renderer draws min(Columns, line width), as it does for a picture. +/// Layout. BGRA, straight alpha, top row first, as +/// is: cells of pixels across and +/// pixels down, read out with . A pixel nothing has +/// painted is fully transparent, so the cell beneath shows through it. is the +/// widest anything has painted and is never clipped to the line, so a terminal widened later shows +/// more rather than having lost it -- the renderer draws min(PaintedColumns, line width), as it +/// does for a picture. /// One scale per strip. A Sixel is laid out against the cell size in force when it /// arrives. A strip painted at another size is resampled to the new one first, so every pixel in it /// agrees on how big a cell is and a renderer maps it to the grid with a single scale. -/// Sharing. A copy of a line (, -/// ) shares its strip, and whichever line writes first takes a -/// private copy, so a frame captured for a renderer costs a reference rather than the pixels. Nothing -/// outside this assembly can write to a strip. +/// Storage, and sharing. The pixels are held in blocks of 16 cells, each allocated only +/// once something paints it -- an implementation detail: hands out an +/// ordinary contiguous image whatever the storage. A copy of a line +/// (, ) shares its strip, +/// and writing to a shared strip copies only the blocks the write touches. A whole row in one array +/// was past the large-object threshold at an ordinary width, and a host that captures frames copies +/// every line of one many times a second -- so each repaint of a captured row copied a large array, +/// and a program streaming full-screen frames (VT DOOM) ran the garbage collector flat out. Blocks +/// are small enough to stay out of the large-object heap, and a repaint of part of a row copies only +/// that part. /// Change. is taken from one counter for every strip and moves /// after each change is complete, so a renderer holding an upload keyed on it knows the upload is /// current, and one that read the pixels while they were being written sees the version move and -/// uploads again. +/// uploads again. Nothing outside this assembly can write to a strip. /// public sealed class SixelStrip { + /// Cells per storage block. See the remarks on the class; nothing outside depends on it. + private const int BlockColumns = 16; + private static long _versionCounter; - private byte[] _pixels; + private byte[]?[] _blocks; + private bool[] _ownsBlock; private bool[] _painted; private int _paintedCount; @@ -42,7 +53,8 @@ private SixelStrip(int cellWidth, int cellHeight, int columns) CellWidth = cellWidth; CellHeight = cellHeight; Columns = columns; - _pixels = new byte[columns * cellWidth * cellHeight * TerminalImage.BytesPerPixel]; + _blocks = new byte[BlocksFor(columns)][]; + _ownsBlock = new bool[_blocks.Length]; _painted = new bool[columns]; Version = NextVersion(); } @@ -62,14 +74,20 @@ private SixelStrip(int cellWidth, int cellHeight, int columns) /// The strip's height in pixels: one cell. public int PixelHeight => CellHeight; - /// Bytes per row of . + /// Bytes per row of the whole strip laid out contiguously, as can. public int Stride => PixelWidth * TerminalImage.BytesPerPixel; - /// The pixels, BGRA with straight alpha, top row first. See the remarks on the class. - public ReadOnlyMemory Pixels => _pixels; - - /// Bytes the pixels occupy. - public long ByteCount => _pixels.LongLength; + /// Bytes the strip's allocated blocks occupy. + public long ByteCount + { + get + { + long bytes = 0; + foreach (var block in _blocks) + bytes += block?.LongLength ?? 0; + return bytes; + } + } /// Changes after every change to the pixels, and is never reused by another strip. public long Version { get; private set; } @@ -95,22 +113,72 @@ public int PaintedColumns } } + /// + /// Copies the first cells of the strip into + /// , BGRA with straight alpha, top row first, + /// bytes per row. Pixels nothing painted are written as + /// transparent, so the destination need not be cleared first. + /// + /// + /// May be called from a thread other than the one writing to the terminal; a copy that overlaps + /// a write may be torn, which moving across the copy reveals. + /// + public void CopyPixelsTo(Span destination, int destinationStride, int columns) + { + columns = Math.Clamp(columns, 0, Columns); + int rowBytes = columns * CellWidth * TerminalImage.BytesPerPixel; + if (destinationStride < rowBytes) + throw new ArgumentOutOfRangeException(nameof(destinationStride)); + if (destination.Length < (long)destinationStride * (CellHeight - 1) + rowBytes) + throw new ArgumentException("The destination is too small for the strip.", nameof(destination)); + + int blockStride = BlockStride; + var blocks = _blocks; + for (int b = 0; b * BlockColumns < columns && b < blocks.Length; b++) + { + int blockColumns = Math.Min(BlockColumns, columns - b * BlockColumns); + int segmentBytes = blockColumns * CellWidth * TerminalImage.BytesPerPixel; + int destX = b * BlockColumns * CellWidth * TerminalImage.BytesPerPixel; + var block = blocks[b]; + + for (int y = 0; y < CellHeight; y++) + { + var target = destination.Slice(y * destinationStride + destX, segmentBytes); + if (block is null) + target.Clear(); + else + block.AsSpan(y * blockStride, segmentBytes).CopyTo(target); + } + } + } + /// Whether every column is clear, in which case the line drops the strip. internal bool IsEmpty => _paintedCount == 0; + /// Bytes per row of one block. + private int BlockStride => BlockColumns * CellWidth * TerminalImage.BytesPerPixel; + + private static int BlocksFor(int columns) => (columns + BlockColumns - 1) / BlockColumns; + private static long NextVersion() => Interlocked.Increment(ref _versionCounter); /// A strip ready to be painted at the given cell size. internal static SixelStrip Create(int cellWidth, int cellHeight, int columns) => new(Math.Max(1, cellWidth), Math.Max(1, cellHeight), Math.Max(1, columns)); - /// A private copy, for a line that shared this strip and is about to write to it. + /// + /// A strip for a line that shared this one and is about to write to it. The blocks are shared, + /// and whichever of the two writes to a block first takes its own copy of it. + /// internal SixelStrip Copy() { var copy = new SixelStrip(CellWidth, CellHeight, Columns); - System.Buffer.BlockCopy(_pixels, 0, copy._pixels, 0, _pixels.Length); + Array.Copy(_blocks, copy._blocks, _blocks.Length); Array.Copy(_painted, copy._painted, _painted.Length); copy._paintedCount = _paintedCount; + + // Neither owns a block any more: both now point at the same arrays. + Array.Clear(_ownsBlock); return copy; } @@ -138,30 +206,40 @@ internal void Paint(TerminalImage image, int sourceY, int column) EnsureColumns(column + imageCols); var rows = Math.Min(CellHeight, image.PixelHeight - sourceY); - var width = image.PixelWidth; var source = image.CurrentPixels.Span; var sourceStride = image.Stride; - var stride = Stride; - var destX = column * CellWidth * TerminalImage.BytesPerPixel; + int blockWidth = BlockColumns * CellWidth; + int blockStride = BlockStride; - for (var y = 0; y < rows; y++) + // The picture's pixels land at strip x = column * CellWidth + x; walk them a block at a time. + int destStart = column * CellWidth; + int destEnd = destStart + image.PixelWidth; + for (int b = destStart / blockWidth; b * blockWidth < destEnd; b++) { - var src = source.Slice((sourceY + y) * sourceStride, width * TerminalImage.BytesPerPixel); - var dst = _pixels.AsSpan(y * stride + destX, width * TerminalImage.BytesPerPixel); + int segStart = Math.Max(destStart, b * blockWidth); + int segEnd = Math.Min(destEnd, (b + 1) * blockWidth); + int segBytes = (segEnd - segStart) * TerminalImage.BytesPerPixel; + var block = WritableBlock(b); - for (var x = 0; x < src.Length; x += TerminalImage.BytesPerPixel) + for (var y = 0; y < rows; y++) { - var alpha = src[x + 3]; - if (alpha == 0) - continue; + var src = source.Slice((sourceY + y) * sourceStride + (segStart - destStart) * TerminalImage.BytesPerPixel, segBytes); + var dst = block.AsSpan(y * blockStride + (segStart - b * blockWidth) * TerminalImage.BytesPerPixel, segBytes); - if (alpha == 255 || dst[x + 3] == 0) + for (var x = 0; x < src.Length; x += TerminalImage.BytesPerPixel) { - src.Slice(x, TerminalImage.BytesPerPixel).CopyTo(dst.Slice(x)); - continue; - } + var alpha = src[x + 3]; + if (alpha == 0) + continue; - Blend(src.Slice(x, TerminalImage.BytesPerPixel), dst.Slice(x, TerminalImage.BytesPerPixel)); + if (alpha == 255 || dst[x + 3] == 0) + { + src.Slice(x, TerminalImage.BytesPerPixel).CopyTo(dst.Slice(x)); + continue; + } + + Blend(src.Slice(x, TerminalImage.BytesPerPixel), dst.Slice(x, TerminalImage.BytesPerPixel)); + } } } @@ -213,18 +291,18 @@ internal void CopyCells(SixelStrip? source, int sourceColumn, int destColumn, in EnsureColumns(destColumn + count); - // Through a scratch copy when the ranges are one strip's: simpler than choosing a direction - // per row, and a shift within a line is a keystroke's worth of work. - var from = ReferenceEquals(source, this) ? Copy() : source; - var cellBytes = CellWidth * TerminalImage.BytesPerPixel; + int cellBytes = CellWidth * TerminalImage.BytesPerPixel; + var scratch = new byte[cellBytes * CellHeight]; - for (var i = 0; i < count; i++) + // Within one strip, walk away from the overlap so no cell is read after it was overwritten. + bool backwards = ReferenceEquals(source, this) && destColumn > sourceColumn; + for (var n = 0; n < count; n++) { + var i = backwards ? count - 1 - n : n; var dest = destColumn + i; var src = sourceColumn + i; - var has = from is not null && from.HasPixelsAt(src); - if (!has) + if (source is null || !source.HasPixelsAt(src)) { if (_painted[dest]) { @@ -235,15 +313,60 @@ internal void CopyCells(SixelStrip? source, int sourceColumn, int destColumn, in continue; } - for (var y = 0; y < CellHeight; y++) - System.Buffer.BlockCopy(from!._pixels, y * from.Stride + src * cellBytes, _pixels, y * Stride + dest * cellBytes, cellBytes); - + source.ReadCell(src, scratch); + WriteCell(dest, scratch); MarkPainted(dest); } Version = NextVersion(); } + /// The block holding , allocated or made private so it can be written. + private byte[] WritableBlock(int index) + { + var block = _blocks[index]; + if (block is null) + { + block = new byte[BlockStride * CellHeight]; + } + else if (!_ownsBlock[index]) + { + block = (byte[])block.Clone(); + } + else + { + return block; + } + + _blocks[index] = block; + _ownsBlock[index] = true; + return block; + } + + private void ReadCell(int column, byte[] cell) + { + int cellBytes = CellWidth * TerminalImage.BytesPerPixel; + var block = _blocks[column / BlockColumns]; + if (block is null) + { + Array.Clear(cell); + return; + } + + int offset = (column % BlockColumns) * cellBytes; + for (var y = 0; y < CellHeight; y++) + System.Buffer.BlockCopy(block, y * BlockStride + offset, cell, y * cellBytes, cellBytes); + } + + private void WriteCell(int column, byte[] cell) + { + int cellBytes = CellWidth * TerminalImage.BytesPerPixel; + var block = WritableBlock(column / BlockColumns); + int offset = (column % BlockColumns) * cellBytes; + for (var y = 0; y < CellHeight; y++) + System.Buffer.BlockCopy(cell, y * cellBytes, block, y * BlockStride + offset, cellBytes); + } + private void MarkPainted(int column) { if (_painted[column]) @@ -254,9 +377,14 @@ private void MarkPainted(int column) private void ClearCell(int column) { - var cellBytes = CellWidth * TerminalImage.BytesPerPixel; + if (_blocks[column / BlockColumns] is null) + return; + + var block = WritableBlock(column / BlockColumns); + int cellBytes = CellWidth * TerminalImage.BytesPerPixel; + int offset = (column % BlockColumns) * cellBytes; for (var y = 0; y < CellHeight; y++) - Array.Clear(_pixels, y * Stride + column * cellBytes, cellBytes); + Array.Clear(block, y * BlockStride + offset, cellBytes); } /// Widens the strip to hold at least cells; new cells are clear. @@ -265,14 +393,14 @@ private void EnsureColumns(int columns) if (columns <= Columns) return; - var oldStride = Stride; - var grown = new byte[columns * CellWidth * CellHeight * TerminalImage.BytesPerPixel]; - var newStride = columns * CellWidth * TerminalImage.BytesPerPixel; - for (var y = 0; y < CellHeight; y++) - System.Buffer.BlockCopy(_pixels, y * oldStride, grown, y * newStride, oldStride); + var blocks = BlocksFor(columns); + if (blocks > _blocks.Length) + { + Array.Resize(ref _blocks, blocks); + Array.Resize(ref _ownsBlock, blocks); + } Array.Resize(ref _painted, columns); - _pixels = grown; Columns = columns; } @@ -285,27 +413,42 @@ private void Resample(int cellWidth, int cellHeight) cellWidth = Math.Max(1, cellWidth); cellHeight = Math.Max(1, cellHeight); + // Read the whole strip out at the old size, then lay it back in at the new one. var oldWidth = PixelWidth; var oldHeight = CellHeight; var oldStride = Stride; - var width = Columns * cellWidth; - var stride = width * TerminalImage.BytesPerPixel; - var resampled = new byte[stride * cellHeight]; + var old = new byte[oldStride * oldHeight]; + CopyPixelsTo(old, oldStride, Columns); + + CellWidth = cellWidth; + CellHeight = cellHeight; + _blocks = new byte[BlocksFor(Columns)][]; + _ownsBlock = new bool[_blocks.Length]; - for (var y = 0; y < cellHeight; y++) + int width = PixelWidth; + int blockWidth = BlockColumns * cellWidth; + for (var b = 0; b < _blocks.Length; b++) { - var sy = Math.Min(oldHeight - 1, y * oldHeight / cellHeight); - for (var x = 0; x < width; x++) + // Only blocks with something painted in them are worth allocating. + var any = false; + for (var c = b * BlockColumns; c < Math.Min(Columns, (b + 1) * BlockColumns) && !any; c++) + any = _painted[c]; + if (!any) + continue; + + var block = WritableBlock(b); + for (var y = 0; y < cellHeight; y++) { - var sx = Math.Min(oldWidth - 1, x * oldWidth / width); - System.Buffer.BlockCopy(_pixels, sy * oldStride + sx * TerminalImage.BytesPerPixel, - resampled, y * stride + x * TerminalImage.BytesPerPixel, TerminalImage.BytesPerPixel); + var sy = Math.Min(oldHeight - 1, y * oldHeight / cellHeight); + for (var x = b * blockWidth; x < Math.Min(width, (b + 1) * blockWidth); x++) + { + var sx = Math.Min(oldWidth - 1, x * oldWidth / width); + System.Buffer.BlockCopy(old, sy * oldStride + sx * TerminalImage.BytesPerPixel, + block, y * BlockStride + (x - b * blockWidth) * TerminalImage.BytesPerPixel, + TerminalImage.BytesPerPixel); + } } } - - _pixels = resampled; - CellWidth = cellWidth; - CellHeight = cellHeight; } /// Source over destination, both straight alpha. diff --git a/src/XTerm.NET/InputHandler.Dcs.cs b/src/XTerm.NET/InputHandler.Dcs.cs index 27d8052..8faa0d9 100644 --- a/src/XTerm.NET/InputHandler.Dcs.cs +++ b/src/XTerm.NET/InputHandler.Dcs.cs @@ -68,7 +68,8 @@ public void HandleDcsHook(string identifier, Params parameters) Math.Max(1, _terminal.Options.CellHeightPixels), _terminal.Options.MaxSixelPixels, (uint)(0xFF000000 | (uint)(_terminal.Colors.Background & 0xFFFFFF)), - palette); + palette, + _sixelCanvas); } /// diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs index 8f4119f..8f4d8e3 100644 --- a/src/XTerm.NET/InputHandler.cs +++ b/src/XTerm.NET/InputHandler.cs @@ -1064,6 +1064,9 @@ public void HandleEsc(string finalChar, string collected) /// The Sixel image being decoded, if a DECSIXEL payload is currently arriving. private Graphics.SixelDecoder? _sixelDecoder; + /// The decoder's buffers, reused from one Sixel to the next. See . + private readonly Graphics.SixelCanvas _sixelCanvas = new(); + /// /// The colour registers used when mode 1070 is reset, so images inherit each other's palette /// the way they did on a VT340. Built on first use, because the default is private registers @@ -1071,6 +1074,9 @@ public void HandleEsc(string finalChar, string collected) /// private Graphics.SixelPalette? _sharedSixelPalette; + /// Returns the shared Sixel colour registers to their defaults (RIS). + internal void ResetSixelPalette() => _sharedSixelPalette = null; + /// The XTGETTCAP request being read, if a DCS + q payload is currently arriving. private StringBuilder? _capabilityRequest; diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs index d88aca1..46f6086 100644 --- a/src/XTerm.NET/Terminal.cs +++ b/src/XTerm.NET/Terminal.cs @@ -348,9 +348,21 @@ internal void SetPageWidth(int columns, bool clear) public bool SixelDisplayMode { get; set; } /// - /// Whether each Sixel image gets its own colour registers (mode 1070). On by default. + /// Whether each Sixel image gets its own colour registers (mode 1070). Off by default: images + /// share one set, as on a VT340 and in Windows Terminal, so a colour defined by one image is + /// still defined for the next. /// - public bool SixelPrivateColorRegisters { get; set; } = true; + /// + /// Shared rather than xterm's private default because programs written against a VT340 or + /// Windows Terminal rely on it. VT DOOM sends its 256 colours only when DOOM's palette changes -- + /// 29 frames in 496 -- and every other frame draws with registers an earlier image set. Given + /// private registers each of those started from the default palette, and the game drew as a + /// scatter of the sixteen defined colours on black. + /// A program that wants a fresh palette per image asks with CSI ? 1070 h; RIS + /// returns both the mode and the shared registers to their defaults, so one program's colours do + /// not carry into the next. + /// + public bool SixelPrivateColorRegisters { get; set; } /// /// Whether the cursor is left to the right of a Sixel image rather than below it (mode 8452). @@ -1250,9 +1262,13 @@ public void Reset() // reset and the next image drew with the previous program's geometry. Their defaults are // not all false, which is why they are named rather than cleared in a loop. SixelDisplayMode = false; - SixelPrivateColorRegisters = true; + SixelPrivateColorRegisters = false; SixelCursorRight = false; + // And the shared colour registers themselves, as a VT340's reset does: registers left set + // by the last program would otherwise colour the next one's Sixels. + _inputHandler.ResetSixelPalette(); + // Same failure, two modes this change added. DECNCSM surviving RIS means the next // DECCOLM silently skips the clean slate DEC guarantees; DECNRCM surviving it means // the next program to designate a national set gets one it never asked for, since @@ -1377,7 +1393,17 @@ public void ScrollLines(int lines) /// internal event Action? SixelPlaced; - internal void NoteSixelPlaced(Graphics.TerminalImage image) => SixelPlaced?.Invoke(image); + internal void NoteSixelPlaced(Graphics.TerminalImage image) + { + if (SixelPlaced is not { } handler) + return; + + // A copy: the decoded picture lives in the decoder's reusable canvas, which the next Sixel + // overwrites, and whoever is listening is keeping it. + var bytes = image.PixelWidth * image.PixelHeight * Graphics.TerminalImage.BytesPerPixel; + var pixels = image.Pixels.Span[..bytes].ToArray(); + handler(new Graphics.TerminalImage(pixels, image.PixelWidth, image.PixelHeight, image.CellWidth, image.CellHeight)); + } internal void NoteImagePlaced(Graphics.TerminalImage image) {