diff --git a/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs b/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs
new file mode 100644
index 0000000..12194e5
--- /dev/null
+++ b/src/XTerm.NET.Tests/Graphics/ImageAssertions.cs
@@ -0,0 +1,91 @@
+using XTerm;
+using XTerm.Graphics;
+
+namespace XTerm.Tests.Graphics;
+
+///
+/// Asking the buffer what a picture covers, now that pictures live in runs on lines.
+///
+///
+/// These replace cell.Image and its companions, which cannot survive the move: a
+/// BufferCell is a struct, so a cell copied out of a line has no idea which line or column it
+/// came from and cannot answer for a run that is anchored to both. The question is the same one the
+/// tests always asked — "is this position showing part of a picture, and which one" — it simply has
+/// to be asked of the line.
+///
+internal static class ImageAssertions
+{
+ /// The run covering a screen position, if any.
+ public static LinePlacement? PlacementAt(Terminal terminal, int col, int screenRow)
+ {
+ var line = terminal.Buffer.Lines[terminal.Buffer.YBase + screenRow];
+ if (line is not null && line.TryGetPlacementAt(col, out var placement))
+ return placement;
+
+ return null;
+ }
+
+ /// The image shown at a screen position, or null for ordinary text.
+ public static TerminalImage? ImageAt(Terminal terminal, int col, int screenRow)
+ {
+ var line = terminal.Buffer.Lines[terminal.Buffer.YBase + screenRow];
+ return line is not null && line.TryGetImageAt(col, out var image) ? image : null;
+ }
+
+ /// Whether a screen position shows part of a picture.
+ public static bool IsImageAt(Terminal terminal, int col, int screenRow)
+ => PlacementAt(terminal, col, screenRow) is not null;
+
+ ///
+ /// How many cells in the whole buffer show part of a picture.
+ ///
+ ///
+ /// Counted from the runs rather than by testing cells, and clamped to each line's width — which
+ /// is the point of the model: a run keeps its natural width, so this reports what is VISIBLE at
+ /// the current size while nothing wider has been destroyed.
+ ///
+ public static int VisibleImageCells(Terminal terminal)
+ {
+ var count = 0;
+
+ for (var i = 0; i < terminal.Buffer.Lines.Length; i++)
+ {
+ var line = terminal.Buffer.Lines[i];
+ if (line is null || !line.HasImages)
+ continue;
+
+ foreach (var placement in line.Placements)
+ {
+ var end = System.Math.Min(placement.EndColumn, line.Length);
+ count += System.Math.Max(0, end - placement.Column);
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// How many cells the buffer's pictures cover at their natural width, visible or not.
+ ///
+ ///
+ /// The difference between this and is exactly what a narrow
+ /// window is hiding rather than destroying — which is the property that used to require hiding
+ /// an overhang and reviving it, and now falls out of the storage.
+ ///
+ public static int TotalImageCells(Terminal terminal)
+ {
+ var count = 0;
+
+ for (var i = 0; i < terminal.Buffer.Lines.Length; i++)
+ {
+ var line = terminal.Buffer.Lines[i];
+ if (line is null || !line.HasImages)
+ continue;
+
+ foreach (var placement in line.Placements)
+ count += placement.Cols;
+ }
+
+ return count;
+ }
+}
diff --git a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
index 6521f7c..752a1b7 100644
--- a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
+++ b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using XTerm.Buffer;
using XTerm.Graphics;
using XTerm.Options;
@@ -48,10 +49,12 @@ private static int ImageCellCount(Terminal terminal)
var line = terminal.Buffer.Lines[i];
if (line is null)
continue;
- for (int x = 0; x < line.Length; x++)
+ // Counted from the runs and clamped to the line's width, so this reports what is
+ // VISIBLE — anything wider is hidden by the window, not destroyed.
+ foreach (var placement in line.Placements)
{
- if (line[x].Image is not null)
- count++;
+ var end = Math.Min(placement.EndColumn, line.Length);
+ count += Math.Max(0, end - placement.Column);
}
}
return count;
@@ -65,13 +68,12 @@ public void Printing_over_a_tile_replaces_it_with_the_character()
terminal.Write($"{Esc}[1;1HX");
- var cell = Cell(terminal, 0, 0);
- Assert.Null(cell.Image);
- Assert.Equal("X", cell.Content);
+ Assert.Null(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.Equal("X", Cell(terminal, 0, 0).Content);
// and only that cell
- Assert.NotNull(Cell(terminal, 1, 0).Image);
- Assert.NotNull(Cell(terminal, 0, 1).Image);
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 1, 0));
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 1));
}
[Fact]
@@ -82,9 +84,9 @@ public void Erase_in_line_clears_the_tiles_on_that_row()
terminal.Write($"{Esc}[1;1H{Esc}[K");
- Assert.Null(Cell(terminal, 0, 0).Image);
- Assert.Null(Cell(terminal, 1, 0).Image);
- Assert.NotNull(Cell(terminal, 0, 1).Image);
+ Assert.Null(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.Null(ImageAssertions.ImageAt(terminal, 1, 0));
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 1));
}
[Fact]
@@ -106,8 +108,8 @@ public void Erase_characters_clears_the_tiles_it_covers()
terminal.Write($"{Esc}[1;1H{Esc}[1X");
- Assert.Null(Cell(terminal, 0, 0).Image);
- Assert.NotNull(Cell(terminal, 1, 0).Image);
+ Assert.Null(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 1, 0));
}
[Fact]
@@ -126,7 +128,7 @@ public void Scrolling_carries_the_tiles_with_their_lines()
{
var terminal = Fresh();
WriteSixel(terminal);
- var image = Cell(terminal, 0, 0).Image;
+ var image = ImageAssertions.ImageAt(terminal, 0, 0);
Assert.NotNull(image);
// The absolute line the top of the image went onto. Scrolling moves the viewport over the
@@ -139,13 +141,14 @@ public void Scrolling_carries_the_tiles_with_their_lines()
Assert.Equal(2, terminal.Buffer.YBase - topLine);
// Nothing copied the tiles anywhere: the same line object still holds them, unchanged.
- var moved = terminal.Buffer.Lines[topLine]![0];
- Assert.True(ReferenceEquals(moved.Image, image),
- "the tile did not travel with its line");
- Assert.Equal(0, moved.ImageRow);
+ var moved = terminal.Buffer.Lines[topLine]!;
+ Assert.True(moved.TryGetImageAt(0, out var movedImage) && ReferenceEquals(movedImage, image),
+ "the run did not travel with its line");
+ Assert.True(moved.TryGetPlacementAt(0, out var movedPlacement));
+ Assert.Equal(0, movedPlacement.SrcY);
// Which puts the top of the picture two rows higher on screen than it was.
- Assert.True(ReferenceEquals(Cell(terminal, 0, -2).Image, image));
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 0, -2), image));
Assert.Equal(8, ImageCellCount(terminal));
}
@@ -168,19 +171,48 @@ public void An_image_scrolled_out_of_the_scrollback_leaves_no_references()
}
///
- /// Reflow re-wraps a logical line by copying ranges of cells between lines, so tiles carried
- /// through it would reassemble as a shuffled mosaic -- every piece intact, in the wrong place.
+ /// A change of width keeps the pictures.
///
+ ///
+ /// This test used to assert the opposite, and the reasoning it carried — reflow re-wraps a
+ /// logical line by copying ranges of cells, so tiles carried through would reassemble as a
+ /// shuffled mosaic — was right about reflow and wrong about the blast radius. It applied to lines
+ /// that actually re-wrap, and the code dropped every picture in the buffer on any width change at
+ /// all, including widening a window, which is the most common resize there is.
+ /// With a picture held as a run on its line rather than tiles in cells, there is nothing to
+ /// shuffle: the renderer draws as much of the run as the width allows. Narrowing shows less,
+ /// widening shows more, and the wrap-chain case is still dropped, on its own, below.
+ ///
[Fact]
- public void A_change_of_width_drops_the_images()
+ public void A_change_of_width_keeps_the_images()
{
var terminal = Fresh();
WriteSixel(terminal);
Assert.Equal(8, ImageCellCount(terminal));
terminal.Resize(15, 10);
+ Assert.Equal(8, ImageCellCount(terminal));
- Assert.Equal(0, ImageCellCount(terminal));
+ terminal.Resize(40, 10);
+ Assert.Equal(8, ImageCellCount(terminal));
+ }
+
+ ///
+ /// Narrowing past a picture hides the overhang rather than destroying it, and widening brings it
+ /// back — because a run keeps its natural width and only the drawing is clipped.
+ ///
+ [Fact]
+ public void Narrowing_past_a_picture_hides_it_and_widening_restores_it()
+ {
+ var terminal = Fresh();
+ WriteSixel(terminal);
+ Assert.Equal(8, ImageCellCount(terminal));
+
+ terminal.Resize(1, 10); // the picture is two columns wide
+ Assert.Equal(4, ImageCellCount(terminal));
+
+ terminal.Resize(20, 10);
+ Assert.Equal(8, ImageCellCount(terminal));
}
/// A change of height moves whole lines, so there is nothing to be confused about.
@@ -219,15 +251,15 @@ public void The_oldest_images_are_dropped_when_the_budget_is_exceeded()
var terminal = Fresh(o => o.MaxImageBytes = 200);
WriteSixel(terminal);
- var first = Cell(terminal, 0, 0).Image;
+ var first = ImageAssertions.ImageAt(terminal, 0, 0);
Assert.NotNull(first);
WriteSixel(terminal);
- var second = Cell(terminal, 0, 4).Image;
+ var second = ImageAssertions.ImageAt(terminal, 0, 4);
Assert.NotNull(second);
- Assert.Null(Cell(terminal, 0, 0).Image);
- Assert.True(ReferenceEquals(Cell(terminal, 0, 4).Image, second),
+ Assert.Null(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 0, 4), second),
"the newest image should be the one that survives");
}
@@ -239,34 +271,107 @@ public void A_generous_budget_keeps_both_images()
WriteSixel(terminal);
WriteSixel(terminal);
- Assert.NotNull(Cell(terminal, 0, 0).Image);
- Assert.NotNull(Cell(terminal, 0, 4).Image);
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 4));
}
///
- /// Two cells showing different pieces of the same picture are not interchangeable, however
- /// alike their text is. Renderers coalesce adjacent cells into one run by comparing them.
+ /// Evicting one picture leaves another that shares its line.
///
+ ///
+ /// Caught in review. Dropping the whole line because one of its pictures was over budget took
+ /// the others with it — more destructive than the per-cell code this replaced, and it evicted
+ /// images the sweep had just decided to keep.
+ ///
[Fact]
- public void Cells_showing_different_tiles_are_not_equal()
+ public void Evicting_one_image_leaves_another_on_the_same_line()
+ {
+ // Two 192-byte images; a 400 byte budget holds both, then a third forces one out.
+ var terminal = Fresh(o => o.MaxImageBytes = 400);
+
+ WriteSixel(terminal);
+ terminal.Write($"{Esc}[1;5H");
+ WriteSixel(terminal);
+
+ var line = terminal.Buffer.Lines[terminal.Buffer.YBase]!;
+ Assert.Equal(2, line.Images.Count);
+ var newer = line.Images[1];
+
+ // A third image pushes past the budget, dooming the oldest — which shares this line.
+ terminal.Write($"{Esc}[10;1H");
+ WriteSixel(terminal);
+
+ Assert.True(ReferenceEquals(newer, line.Images.SingleOrDefault()),
+ "the line's other picture should survive its neighbour being evicted");
+ }
+
+ ///
+ /// Printing over one picture releases it, while another on the same line stays.
+ ///
+ ///
+ /// Caught in review. Ownership is derived from the runs, so anything that removes one has to
+ /// rebuild it — waiting for the run list to empty kept a picture alive that nothing displayed,
+ /// and hid it from the budget sweep, which decides what is live by walking runs.
+ ///
+ [Fact]
+ public void Overwriting_one_picture_releases_only_that_one()
{
var terminal = Fresh();
+
WriteSixel(terminal);
+ terminal.Write($"{Esc}[1;5H");
+ WriteSixel(terminal);
+
+ var line = terminal.Buffer.Lines[terminal.Buffer.YBase]!;
+ Assert.Equal(2, line.Images.Count);
+ var survivor = line.Images[1];
- var left = Cell(terminal, 0, 0);
- var right = Cell(terminal, 1, 0);
+ // Print across the whole span of the first picture, which covers columns 0..1.
+ terminal.Write($"{Esc}[1;1HXX");
- Assert.Equal(left.Content, right.Content);
- Assert.NotEqual(left, right);
+ Assert.True(ReferenceEquals(survivor, line.Images.SingleOrDefault()),
+ "the overwritten picture should be released and the other kept");
}
+ ///
+ /// A cell under a picture is an ordinary space, and that is now correct rather than a bug.
+ ///
+ ///
+ /// This inverts what these tests used to assert. When a cell carried an image reference and a
+ /// tile coordinate, two cells showing different pieces of a picture HAD to compare unequal,
+ /// because renderers coalesce adjacent cells into one run by comparing them and merging two
+ /// different tiles would have drawn the wrong thing. With pictures held as runs on the line,
+ /// nothing about a picture is drawn from cells — so cells beneath one should coalesce exactly
+ /// like the spaces they are, and the distinction has no work left to do.
+ ///
[Fact]
- public void An_image_cell_is_not_equal_to_a_plain_space()
+ public void A_cell_under_a_picture_is_an_ordinary_space()
{
var terminal = Fresh();
WriteSixel(terminal);
- Assert.NotEqual(BufferCell.Space, Cell(terminal, 0, 0));
+ Assert.Equal(BufferCell.Space, Cell(terminal, 0, 0));
+ Assert.Equal(Cell(terminal, 0, 0), Cell(terminal, 1, 0));
+
+ // And the picture is still there — held by the line, which is the whole point.
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 0));
+ }
+
+ ///
+ /// What must stay distinguishable is the RUNS: each line shows its own slice of the picture.
+ ///
+ [Fact]
+ public void Runs_on_different_lines_carry_different_slices()
+ {
+ var terminal = Fresh();
+ WriteSixel(terminal);
+
+ var first = ImageAssertions.PlacementAt(terminal, 0, 0);
+ var second = ImageAssertions.PlacementAt(terminal, 0, 1);
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.NotEqual(first!.Value.SrcY, second!.Value.SrcY);
}
[Fact]
diff --git a/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs b/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs
index 4510e58..4d16db1 100644
--- a/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs
+++ b/src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs
@@ -38,7 +38,7 @@ private static void WriteSixel(Terminal terminal, string body, int backgroundSel
{
var terminal = Fresh(configure);
WriteSixel(terminal, body, backgroundSelect);
- return terminal.Buffer.Lines[0]![0].Image;
+ return (terminal.Buffer.Lines[0]!.TryGetImageAt(0, out var __i1) ? __i1 : null);
}
private static TerminalImage Decode(string body, int backgroundSelect = Transparent,
@@ -232,7 +232,7 @@ public void An_abandoned_payload_produces_no_image()
// CAN mid-payload: the sequence is dropped rather than terminated.
terminal.Write($"{Esc}P0;1;0q#0;2;100;0;0!20~\u0018");
- Assert.Null(terminal.Buffer.Lines[0]![0].Image);
+ Assert.Null((terminal.Buffer.Lines[0]!.TryGetImageAt(0, out var __i2) ? __i2 : null));
}
[Fact]
@@ -241,7 +241,7 @@ public void An_empty_payload_produces_no_image()
var terminal = Fresh();
terminal.Write($"{Esc}P0;1;0q{St}");
- Assert.Null(terminal.Buffer.Lines[0]![0].Image);
+ Assert.Null((terminal.Buffer.Lines[0]!.TryGetImageAt(0, out var __i3) ? __i3 : null));
}
///
@@ -283,8 +283,8 @@ public void A_payload_split_across_writes_decodes_the_same()
split.Write("0;0;0!4~-#1;2;0");
split.Write($";0;100!4~{St}");
- var a = whole.Buffer.Lines[0]![0].Image;
- var b = split.Buffer.Lines[0]![0].Image;
+ var a = whole.Buffer.Lines[0]!.TryGetImageAt(0, out var wholeImage) ? wholeImage : null;
+ var b = split.Buffer.Lines[0]!.TryGetImageAt(0, out var splitImage) ? splitImage : null;
Assert.NotNull(a);
Assert.NotNull(b);
@@ -300,7 +300,7 @@ public void Sixel_can_be_switched_off_entirely()
var terminal = Fresh(o => o.SixelEnabled = false);
WriteSixel(terminal, "#0;2;100;0;0~");
- Assert.Null(terminal.Buffer.Lines[0]![0].Image);
+ Assert.Null((terminal.Buffer.Lines[0]!.TryGetImageAt(0, out var __i4) ? __i4 : null));
}
[Fact]
diff --git a/src/XTerm.NET.Tests/Graphics/SixelPlacementTests.cs b/src/XTerm.NET.Tests/Graphics/SixelPlacementTests.cs
index 6869e07..18cd636 100644
--- a/src/XTerm.NET.Tests/Graphics/SixelPlacementTests.cs
+++ b/src/XTerm.NET.Tests/Graphics/SixelPlacementTests.cs
@@ -44,21 +44,25 @@ public void An_image_covers_one_cell_per_tile()
var terminal = Fresh();
WriteSixel(terminal);
- var image = Cell(terminal, 0, 0).Image;
+ var image = ImageAssertions.ImageAt(terminal, 0, 0);
Assert.NotNull(image);
Assert.Equal(2, image!.Cols);
Assert.Equal(4, image.Rows);
+ // One run per line, each covering the picture's full width and carrying its own slice of
+ // the source — which is what the per-cell tile grid said, expressed once per row instead of
+ // once per cell.
for (int row = 0; row < 4; row++)
{
- for (int col = 0; col < 2; col++)
- {
- var cell = Cell(terminal, col, row);
- Assert.True(ReferenceEquals(cell.Image, image),
- $"cell ({col},{row}) should show part of the image");
- Assert.Equal(col, cell.ImageCol);
- Assert.Equal(row, cell.ImageRow);
- }
+ var placement = ImageAssertions.PlacementAt(terminal, 0, row);
+ Assert.NotNull(placement);
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 0, row), image),
+ $"row {row} should show part of the image");
+
+ Assert.Equal(0, placement!.Value.Column);
+ Assert.Equal(2, placement.Value.Cols);
+ Assert.Equal(0, placement.Value.SrcX);
+ Assert.Equal(row * terminal.Options.CellHeightPixels, placement.Value.SrcY);
}
}
@@ -68,8 +72,8 @@ public void Cells_beyond_the_image_are_left_alone()
var terminal = Fresh();
WriteSixel(terminal);
- Assert.Null(Cell(terminal, 2, 0).Image);
- Assert.Null(Cell(terminal, 0, 4).Image);
+ Assert.Null(ImageAssertions.ImageAt(terminal, 2, 0));
+ Assert.Null(ImageAssertions.ImageAt(terminal, 0, 4));
}
[Fact]
@@ -79,9 +83,9 @@ public void An_image_starts_at_the_cursor()
terminal.Write($"{Esc}[3;6H"); // row 3, column 6, one-based
WriteSixel(terminal);
- Assert.True(ReferenceEquals(Cell(terminal, 5, 2).Image, Cell(terminal, 6, 2).Image));
- Assert.NotNull(Cell(terminal, 5, 2).Image);
- Assert.Null(Cell(terminal, 4, 2).Image);
+ Assert.True(ReferenceEquals(ImageAssertions.ImageAt(terminal, 5, 2), ImageAssertions.ImageAt(terminal, 6, 2)));
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 5, 2));
+ Assert.Null(ImageAssertions.ImageAt(terminal, 4, 2));
}
[Fact]
@@ -129,8 +133,8 @@ public void Decsdm_pins_the_image_to_the_top_left_and_leaves_the_cursor_alone()
terminal.Write($"{Esc}[3;6H");
WriteSixel(terminal);
- Assert.NotNull(Cell(terminal, 0, 0).Image);
- Assert.Null(Cell(terminal, 5, 2).Image);
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 0, 0));
+ Assert.Null(ImageAssertions.ImageAt(terminal, 5, 2));
Assert.Equal(5, terminal.Buffer.X);
Assert.Equal(2, terminal.Buffer.Y);
}
@@ -144,7 +148,7 @@ public void Decsdm_reset_restores_the_scrolling_behaviour()
terminal.Write($"{Esc}[3;6H");
WriteSixel(terminal);
- Assert.NotNull(Cell(terminal, 5, 2).Image);
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 5, 2));
}
///
@@ -160,9 +164,9 @@ public void An_image_that_runs_past_the_bottom_scrolls_the_screen()
// Four image rows plus the cursor's own row need five: the screen scrolled until they fit.
for (int row = 0; row < 4; row++)
{
- var cell = Cell(terminal, 0, row);
- Assert.NotNull(cell.Image);
- Assert.Equal(row, cell.ImageRow);
+ var placement = ImageAssertions.PlacementAt(terminal, 0, row);
+ Assert.NotNull(placement);
+ Assert.Equal(row * terminal.Options.CellHeightPixels, placement!.Value.SrcY);
}
Assert.Equal(0, terminal.Buffer.X);
@@ -177,8 +181,8 @@ public void An_image_taller_than_the_screen_keeps_its_last_rows()
// Three rows of screen, four of image, and the cursor still needs a row of its own below
// it -- so the picture scrolled up until its last two rows and the cursor fit.
- Assert.Equal(2, Cell(terminal, 0, 0).ImageRow);
- Assert.Equal(3, Cell(terminal, 0, 1).ImageRow);
+ Assert.Equal(2 * terminal.Options.CellHeightPixels, ImageAssertions.PlacementAt(terminal, 0, 0)!.Value.SrcY);
+ Assert.Equal(3 * terminal.Options.CellHeightPixels, ImageAssertions.PlacementAt(terminal, 0, 1)!.Value.SrcY);
Assert.Equal(2, terminal.Buffer.Y);
}
@@ -190,8 +194,8 @@ public void Decsdm_clips_a_tall_image_instead_of_scrolling()
WriteSixel(terminal);
// Pinned at the top, so the first rows are the ones that survive.
- Assert.Equal(0, Cell(terminal, 0, 0).ImageRow);
- Assert.Equal(2, Cell(terminal, 0, 2).ImageRow);
+ Assert.Equal(0 * terminal.Options.CellHeightPixels, ImageAssertions.PlacementAt(terminal, 0, 0)!.Value.SrcY);
+ Assert.Equal(2 * terminal.Options.CellHeightPixels, ImageAssertions.PlacementAt(terminal, 0, 2)!.Value.SrcY);
}
[Fact]
@@ -201,8 +205,8 @@ public void An_image_is_clipped_at_the_right_edge()
terminal.Write($"{Esc}[1;6H"); // one column from the right edge
WriteSixel(terminal);
- Assert.NotNull(Cell(terminal, 5, 0).Image);
- Assert.Equal(0, Cell(terminal, 5, 0).ImageCol);
+ Assert.NotNull(ImageAssertions.ImageAt(terminal, 5, 0));
+ Assert.Equal(0 * terminal.Options.CellWidthPixels, ImageAssertions.PlacementAt(terminal, 5, 0)!.Value.SrcX);
}
///
@@ -245,8 +249,8 @@ public void Two_images_do_not_share_tiles()
WriteSixel(terminal);
WriteSixel(terminal);
- var first = Cell(terminal, 0, 0).Image;
- var second = Cell(terminal, 0, 4).Image;
+ var first = ImageAssertions.ImageAt(terminal, 0, 0);
+ var second = ImageAssertions.ImageAt(terminal, 0, 4);
Assert.NotNull(first);
Assert.NotNull(second);
diff --git a/src/XTerm.NET/Buffer/BufferCell.cs b/src/XTerm.NET/Buffer/BufferCell.cs
index 82b0d19..5de9766 100644
--- a/src/XTerm.NET/Buffer/BufferCell.cs
+++ b/src/XTerm.NET/Buffer/BufferCell.cs
@@ -9,7 +9,7 @@ namespace XTerm.Buffer;
/// Represents a single cell in the terminal buffer.
/// Each cell contains a character, width, and attributes.
///
-[DebuggerDisplay("'{Content}' [{Width}, {Attributes}, {CodePoint}]{Image != null ? \" image\" : \"\"}")]
+[DebuggerDisplay("'{Content}' [{Width}, {Attributes}, {CodePoint}]")]
public struct BufferCell : IEquatable
{
public string Content = String.Empty;
@@ -17,41 +17,6 @@ public struct BufferCell : IEquatable
public AttributeData Attributes = AttributeData.Default;
public int CodePoint = 0;
- ///
- /// The image this cell shows a piece of, or null for an ordinary text cell.
- ///
- ///
- /// Living on the cell rather than in a separate overlay is what makes an image behave
- /// like terminal content. Printing a character builds a whole new cell, so the image reference
- /// goes with the old one; erasing fills with a blank cell, which has no image; scrolling moves
- /// whole lines, so the pieces travel together. None of that needed code -- it is what a struct
- /// copied by value already does.
- /// The image itself is shared by every cell covering it, and dies with the last one, so
- /// a picture scrolled off the end of the scrollback is collected without an eviction pass.
- ///
- public TerminalImage? Image = null;
-
- ///
- /// Which piece of this cell shows, packed as (row << 16) | column.
- /// Meaningless when is null.
- ///
- ///
- /// Packed because the reference above already forces the struct onto an eight-byte boundary,
- /// which leaves four bytes of padding this fits into for free. Two separate ints would not.
- ///
- public int ImageTile = 0;
-
- /// The column of 's tile grid that this cell shows.
- public readonly int ImageCol => ImageTile & 0xFFFF;
-
- /// The row of 's tile grid that this cell shows.
- public readonly int ImageRow => (ImageTile >> 16) & 0xFFFF;
-
- /// Whether this cell shows part of an image.
- public readonly bool IsImage => Image is not null;
-
- /// Packs tile coordinates for .
- public static int PackTile(int col, int row) => ((row & 0xFFFF) << 16) | (col & 0xFFFF);
public static BufferCell Empty => new BufferCell();
@@ -90,15 +55,14 @@ public BufferCell(int codePoint, int width, AttributeData attributes)
public bool Equals(BufferCell other)
{
- // Image identity is part of cell equality, and not only for tests: renderers coalesce
- // adjacent cells into a single run by comparing them, and two cells showing different
- // pieces of a picture are not interchangeable however alike their text is.
+ // No image term any more. A picture is a run on the line, so a cell beneath one is an
+ // ordinary space and SHOULD compare equal to one — renderers coalesce adjacent cells into a
+ // single run by comparing them, and merging cells under a picture is now correct, because
+ // nothing about the picture is drawn from cells.
return Content == other.Content &&
Width == other.Width &&
Attributes.Equals(other.Attributes) &&
- CodePoint == other.CodePoint &&
- ReferenceEquals(Image, other.Image) &&
- (Image is null || ImageTile == other.ImageTile);
+ CodePoint == other.CodePoint;
}
public override bool Equals(object? obj)
@@ -108,9 +72,7 @@ public override bool Equals(object? obj)
public override int GetHashCode()
{
- return HashCode.Combine(Content, Width, Attributes, CodePoint,
- Image is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(Image),
- Image is null ? 0 : ImageTile);
+ return HashCode.Combine(Content, Width, Attributes, CodePoint);
}
public static bool operator ==(BufferCell left, BufferCell right)
diff --git a/src/XTerm.NET/Buffer/BufferLine.cs b/src/XTerm.NET/Buffer/BufferLine.cs
index 69bb9a1..c3dc7e0 100644
--- a/src/XTerm.NET/Buffer/BufferLine.cs
+++ b/src/XTerm.NET/Buffer/BufferLine.cs
@@ -10,6 +10,23 @@ namespace XTerm.Buffer;
public class BufferLine : IEnumerable
{
private BufferCell[] _cells;
+
+ ///
+ /// The picture runs shown on this line, or null — which is every line, in almost every session.
+ ///
+ ///
+ /// This is where a picture LIVES. Cells carry no image data at all, so nothing about a picture
+ /// is destroyed by anything that truncates or overwrites cells, and a resize needs to do nothing
+ /// to images whatsoever: the renderer draws as much of each run as the current width allows.
+ ///
+ private List? _placements;
+
+ ///
+ /// The images those runs refer to, held strongly so they stay alive exactly as long as this line
+ /// does — so a picture scrolled off the end of the scrollback dies with the last line showing it,
+ /// with no eviction pass and nothing to keep in step with a buffer that scrolls.
+ ///
+ private List? _images;
private int _length;
private bool _isWrapped;
private LineAttribute _lineAttribute;
@@ -91,6 +108,13 @@ public void SetCell(int index, ref BufferCell cell)
if (index >= 0 && index < _length)
{
_cells[index] = cell;
+
+ // Printing over a Sixel picture replaces that part of it. With tiles in cells this
+ // happened for free; with runs it is explicit. One field test on the overwhelmingly
+ // common line, which has no pictures at all.
+ if (_placements is not null)
+ SplitPlacementsAt(index);
+
Cache = null;
}
}
@@ -145,6 +169,11 @@ public void Fill(BufferCell fillCell, int startCol = 0, int endCol = -1)
{
_cells[i] = fillCell;
}
+
+ // Erasing takes any picture in the span with it, the same as printing over one does.
+ if (_placements is not null)
+ SplitPlacementsOver(startCol, Math.Max(0, Math.Min(endCol, _length) - startCol));
+
Cache = null;
}
@@ -246,40 +275,237 @@ public void ReplaceCells(int startCol, int endCol, BufferCell fillCell)
/// True if the line held any, which is also the signal that it needs repainting.
public bool ClearImages()
{
- bool found = false;
- for (int i = 0; i < _length; i++)
- {
- if (_cells[i].Image is null)
- continue;
+ if (_placements is null && _images is null)
+ return false;
+
+ // Nothing to clean up in the cells — they never held anything. Releasing the strong
+ // references is what actually frees the pixels.
+ _placements = null;
+ _images = null;
+ Cache = null;
+ return true;
+ }
+
+ ///
+ /// Whether this line shows any part of a picture. One field test — a renderer can ask per row.
+ ///
+ public bool HasImages => _placements is { Count: > 0 };
- _cells[i].Image = null;
- _cells[i].ImageTile = 0;
- _cells[i].Content = " ";
- _cells[i].Width = 1;
- _cells[i].CodePoint = 0x20;
- found = true;
+ ///
+ /// The distinct images this line shows.
+ ///
+ ///
+ /// The list to walk when the question is "which pictures are on this line" — asking column by
+ /// column both costs more and answers wrongly, because a column covered by two overlapping runs
+ /// reports only the first, and an image seen through no other column would be missed entirely.
+ ///
+ public IReadOnlyList Images
+ => (IReadOnlyList?)_images ?? Array.Empty();
+
+ /// The picture runs on this line, in the order they were placed.
+ public IReadOnlyList Placements
+ => (IReadOnlyList?)_placements ?? Array.Empty();
+
+ ///
+ /// The run covering , if any.
+ ///
+ ///
+ /// This is what replaces asking a CELL about its image. A cell is a struct with no idea which
+ /// line or column it came from, so it cannot answer for a run anchored to both — the question
+ /// can only be asked here. Linear over the runs, of which a line has one or a handful.
+ ///
+ public bool TryGetPlacementAt(int column, out Graphics.LinePlacement placement)
+ {
+ if (_placements is not null)
+ {
+ for (int i = 0; i < _placements.Count; i++)
+ {
+ if (_placements[i].Covers(column))
+ {
+ placement = _placements[i];
+ return true;
+ }
+ }
}
- if (found)
- Cache = null;
- return found;
+ placement = default;
+ return false;
}
///
- /// Whether any cell on this line shows part of an image. Cheap enough for a renderer to ask
- /// once per row rather than testing every cell it draws.
+ /// The image shown at , if any.
///
- public bool HasImages
+ ///
+ /// Resolved from the line's own strong references, so a caller gets the picture without knowing
+ /// that ids exist and without touching a weak table it might race.
+ ///
+ public bool TryGetImageAt(int column, out Graphics.TerminalImage image)
{
- get
+ if (TryGetPlacementAt(column, out var placement) && _images is not null)
{
- for (int i = 0; i < _length; i++)
+ foreach (var held in _images)
{
- if (_cells[i].Image is not null)
+ if (held.Id == placement.ImageId)
+ {
+ image = held;
return true;
+ }
+ }
+ }
+
+ image = null!;
+ return false;
+ }
+
+ ///
+ /// Drops the strong reference to any image this line no longer shows.
+ ///
+ ///
+ /// Ownership is derived from the runs, not tracked alongside them, so anything that
+ /// removes a run has to rebuild it. Otherwise a line keeps a picture alive that nothing on it
+ /// displays any more — and worse, the budget sweep walks runs to decide what is live, so such a
+ /// picture is invisible to it and can never be reclaimed.
+ /// Linear in runs times images, both of which are one or a handful.
+ ///
+ private void PruneImages()
+ {
+ if (_images is null)
+ return;
+
+ for (int i = _images.Count - 1; i >= 0; i--)
+ {
+ var id = _images[i].Id;
+ var stillShown = false;
+
+ if (_placements is not null)
+ {
+ foreach (var placement in _placements)
+ {
+ if (placement.ImageId == id)
+ {
+ stillShown = true;
+ break;
+ }
+ }
}
+
+ if (!stillShown)
+ _images.RemoveAt(i);
+ }
+
+ if (_images.Count == 0)
+ _images = null;
+ }
+
+ ///
+ /// Removes every run showing one of , leaving the rest alone.
+ ///
+ /// True if anything was removed, which is also the signal to repaint.
+ ///
+ /// Selective on purpose. Clearing the whole line because one of its pictures was doomed would
+ /// take the others with it, which is more destructive than the per-cell code this replaced.
+ ///
+ internal bool RemoveImages(HashSet doomed)
+ {
+ if (_placements is null || _images is null)
+ return false;
+
+ var doomedIds = new HashSet();
+ foreach (var image in _images)
+ {
+ if (doomed.Contains(image))
+ doomedIds.Add(image.Id);
+ }
+
+ if (doomedIds.Count == 0)
+ return false;
+
+ var removed = _placements.RemoveAll(p => doomedIds.Contains(p.ImageId)) > 0;
+ if (!removed)
return false;
+
+ if (_placements.Count == 0)
+ _placements = null;
+
+ PruneImages();
+ Cache = null;
+ return true;
+ }
+
+ /// Adds a run to this line and takes ownership of the image it shows.
+ internal void AddPlacement(Graphics.LinePlacement placement, Graphics.TerminalImage image)
+ {
+ if (placement.Cols <= 0)
+ return;
+
+ _placements ??= new List(1);
+ _placements.Add(placement);
+
+ _images ??= new List(1);
+ foreach (var held in _images)
+ {
+ if (ReferenceEquals(held, image))
+ {
+ Cache = null;
+ return;
+ }
+ }
+
+ _images.Add(image);
+ Cache = null;
+ }
+
+ ///
+ /// Splits any Sixel run covering around the text just written there.
+ ///
+ ///
+ /// Sixel semantics: printing replaces that part of the picture. With tiles in cells this
+ /// happened for free, because the write overwrote the cell; with runs it has to be done on
+ /// purpose. The run becomes the fragments either side, each with its source rectangle narrowed
+ /// to match, so the rest of the picture survives a character landing in the middle of it.
+ /// Kitty runs are left alone — there the z-index decides what is on top, and text never
+ /// modifies a placement.
+ /// Guarded on a null field at every call site, so a line without pictures — which is
+ /// nearly every line — pays a single test.
+ ///
+ internal void SplitPlacementsAt(int column)
+ {
+ if (_placements is null)
+ return;
+
+ for (int i = _placements.Count - 1; i >= 0; i--)
+ {
+ var placement = _placements[i];
+ if (placement.Kind != Graphics.PlacementKind.Sixel || !placement.Covers(column))
+ continue;
+
+ _placements.RemoveAt(i);
+
+ var before = placement.TruncatedBefore(column);
+ if (before.Cols > 0)
+ _placements.Insert(i, before);
+
+ var after = placement.TruncatedAfter(column);
+ if (after.Cols > 0)
+ _placements.Insert(before.Cols > 0 ? i + 1 : i, after);
}
+
+ if (_placements.Count == 0)
+ _placements = null;
+
+ // A split can remove the last run for ONE image while runs for others remain, so this
+ // cannot wait for the list to empty.
+ PruneImages();
+ }
+
+ /// Splits runs across a whole written span.
+ internal void SplitPlacementsOver(int column, int count)
+ {
+ if (_placements is null)
+ return;
+
+ for (int i = 0; i < count; i++)
+ SplitPlacementsAt(column + i);
}
///
@@ -303,6 +529,13 @@ public BufferLine Clone()
var newLine = new BufferLine(_length);
newLine._isWrapped = _isWrapped;
newLine._lineAttribute = _lineAttribute;
+
+ // The runs are the picture, so a clone that skipped them would silently lose it.
+ if (_placements is not null)
+ {
+ newLine._placements = new List(_placements);
+ newLine._images = _images is null ? null : new List(_images);
+ }
for (int i = 0; i < _length; i++)
{
newLine._cells[i] = _cells[i];
diff --git a/src/XTerm.NET/Buffer/TerminalBuffer.cs b/src/XTerm.NET/Buffer/TerminalBuffer.cs
index 66d2fc2..9c1f3db 100644
--- a/src/XTerm.NET/Buffer/TerminalBuffer.cs
+++ b/src/XTerm.NET/Buffer/TerminalBuffer.cs
@@ -332,14 +332,26 @@ public int GetAbsoluteY(int y)
///
public void Resize(int newCols, int newRows)
{
- // Images do not survive a change of width. Reflow re-wraps a logical line by copying
- // ranges of cells between lines, and the tiles would be carried along individually and
- // reassemble as a shuffled mosaic -- each piece of the picture intact, in the wrong place.
- // Dropping them is what the user sees anyway when a terminal is made narrower, and it is
- // honest about it. A change of height alone moves whole lines and leaves images be.
+ // Only a wrap chain drops its pictures now. Reflow re-wraps a logical line by copying
+ // ranges of cells between lines, and a run anchored to a column would end up describing
+ // content that is no longer there.
+ //
+ // Every other width change is free. A run keeps its NATURAL width and the renderer draws as
+ // much of it as the line allows, so narrowing shows less of a picture and widening shows
+ // more — with nothing destroyed and nothing to restore. Dropping every image on any width
+ // change, as this did, lost pictures on the most common resize there is.
if (newCols != _cols)
{
- ClearImages();
+ for (int i = 0; i < _lines.Length; i++)
+ {
+ var line = _lines[i];
+ if (line is null || !line.HasImages)
+ continue;
+
+ var next = i + 1 < _lines.Length ? _lines[i + 1] : null;
+ if (line.IsWrapped || next is { IsWrapped: true })
+ line.ClearImages();
+ }
}
var nullCell = BufferCell.Space;
diff --git a/src/XTerm.NET/Graphics/LinePlacement.cs b/src/XTerm.NET/Graphics/LinePlacement.cs
new file mode 100644
index 0000000..3c65f9f
--- /dev/null
+++ b/src/XTerm.NET/Graphics/LinePlacement.cs
@@ -0,0 +1,143 @@
+namespace XTerm.Graphics;
+
+///
+/// Which semantics a placement follows when text is written over it.
+///
+public enum PlacementKind
+{
+ ///
+ /// Sixel: printing replaces that part of the picture, so a write into a placement's span splits
+ /// it around the written columns.
+ ///
+ Sixel = 0,
+
+ ///
+ /// Kitty graphics: the z-index decides, and text may draw over or under. A write does not modify
+ /// the placement at all.
+ ///
+ Kitty = 1,
+}
+
+///
+/// A run of one image shown on one line — the storage that replaces scattering a picture's tiles
+/// across cells.
+///
+///
+/// A picture spanning eight rows is eight placements, one per line, each with its own
+/// . Keeping every placement line-local is what lets ownership, scrolling and
+/// scrollback eviction go on working exactly as they did when cells held the tiles: the line is
+/// still the thing that owns a picture and still the thing whose death releases it.
+///
+/// is the natural width and is never clipped. The renderer draws
+/// min(Cols, line width). That single decision is what makes a resize a no-op for images:
+/// narrowing shows less of the picture, widening shows more, and nothing is destroyed or restored
+/// because the pixels were never in the grid. It replaces hiding an overhang on narrow, reviving
+/// tiles on widen, and pruning ownership after a sweep.
+///
+public readonly struct LinePlacement
+{
+ /// The image this shows, by .
+ public readonly int ImageId;
+
+ ///
+ /// The placement's own id, for protocols that address placements separately from images
+ /// (Kitty's p=). Zero for Sixel, which has no way to refer to one.
+ ///
+ public readonly int PlacementId;
+
+ /// The column on this line where the run starts.
+ public readonly int Column;
+
+ ///
+ /// How many cells the run covers if it is fully visible. Never clipped to the line's width —
+ /// see the remarks on this type for why that is the whole point.
+ ///
+ public readonly int Cols;
+
+ /// The source rectangle within the image, in pixels.
+ public readonly int SrcX;
+ public readonly int SrcY;
+ public readonly int SrcWidth;
+ public readonly int SrcHeight;
+
+ /// Pixel offset within the starting cell (Kitty's X and Y).
+ public readonly short OffsetX;
+ public readonly short OffsetY;
+
+ /// Draw order against text. Negative draws beneath glyphs (Kitty's z).
+ public readonly short ZIndex;
+
+ /// How this placement behaves when text is written across it.
+ public readonly PlacementKind Kind;
+
+ public LinePlacement(
+ int imageId,
+ int column,
+ int cols,
+ int srcX,
+ int srcY,
+ int srcWidth,
+ int srcHeight,
+ PlacementKind kind = PlacementKind.Sixel,
+ int placementId = 0,
+ short offsetX = 0,
+ short offsetY = 0,
+ short zIndex = 0)
+ {
+ ImageId = imageId;
+ Column = column;
+ Cols = cols;
+ SrcX = srcX;
+ SrcY = srcY;
+ SrcWidth = srcWidth;
+ SrcHeight = srcHeight;
+ Kind = kind;
+ PlacementId = placementId;
+ OffsetX = offsetX;
+ OffsetY = offsetY;
+ ZIndex = zIndex;
+ }
+
+ /// One past the last column this run covers when fully visible.
+ public int EndColumn => Column + Cols;
+
+ /// Whether falls inside this run.
+ public bool Covers(int column) => column >= Column && column < EndColumn;
+
+ ///
+ /// The part of this run left of , with its source rectangle narrowed to
+ /// match. Used when text is printed into the middle of a Sixel picture.
+ ///
+ public LinePlacement TruncatedBefore(int column)
+ {
+ var cols = System.Math.Max(0, column - Column);
+ return WithColumns(Column, cols, SrcX);
+ }
+
+ ///
+ /// The part of this run right of , with its source rectangle advanced to
+ /// match.
+ ///
+ public LinePlacement TruncatedAfter(int column)
+ {
+ var start = column + 1;
+ var cols = System.Math.Max(0, EndColumn - start);
+ var skipped = start - Column;
+
+ // Source width per cell, so the remaining rectangle starts where the dropped cells ended.
+ var perCell = Cols > 0 ? (double)SrcWidth / Cols : 0;
+ return WithColumns(start, cols, SrcX + (int)System.Math.Round(skipped * perCell));
+ }
+
+ private LinePlacement WithColumns(int column, int cols, int srcX)
+ {
+ var perCell = Cols > 0 ? (double)SrcWidth / Cols : 0;
+ var width = (int)System.Math.Round(cols * perCell);
+
+ return new LinePlacement(
+ ImageId, column, cols,
+ srcX, SrcY,
+ System.Math.Min(width, System.Math.Max(0, SrcX + SrcWidth - srcX)), SrcHeight,
+ Kind, PlacementId, OffsetX, OffsetY, ZIndex);
+ }
+}
diff --git a/src/XTerm.NET/InputHandler.cs b/src/XTerm.NET/InputHandler.cs
index f9283a0..9e1a7e3 100644
--- a/src/XTerm.NET/InputHandler.cs
+++ b/src/XTerm.NET/InputHandler.cs
@@ -854,18 +854,22 @@ private void PlaceImage(Graphics.TerminalImage image)
if (line is null)
break;
- for (int tileCol = 0; tileCol < image.Cols; tileCol++)
+ // One run per line instead of a cell per tile. The line takes ownership of the image,
+ // and Cols is the picture's NATURAL width — deliberately NOT clipped to the terminal, so
+ // a window widened later reveals more of the picture rather than having lost it.
+ if (image.TryGetTileSource(0, tileRow, out _, out var srcY, out _, out var srcHeight))
{
- var col = startCol + tileCol;
- if (col >= _terminal.Cols)
- break;
-
- var cell = new BufferCell(" ", 1, _curAttr)
- {
- Image = image,
- ImageTile = BufferCell.PackTile(tileCol, tileRow)
- };
- line.SetCell(col, ref cell);
+ line.AddPlacement(
+ new Graphics.LinePlacement(
+ image.Id,
+ startCol,
+ image.Cols,
+ srcX: 0,
+ srcY: srcY,
+ srcWidth: image.PixelWidth,
+ srcHeight: srcHeight,
+ kind: Graphics.PlacementKind.Sixel),
+ image);
}
lastRowDrawn = row;
diff --git a/src/XTerm.NET/Terminal.cs b/src/XTerm.NET/Terminal.cs
index 798e742..45ee4e5 100644
--- a/src/XTerm.NET/Terminal.cs
+++ b/src/XTerm.NET/Terminal.cs
@@ -555,12 +555,11 @@ void Collect(Buffer.TerminalBuffer buffer)
var line = buffer.Lines[i];
if (line is null)
continue;
- for (int x = 0; x < line.Length; x++)
- {
- var image = line[x].Image;
- if (image is not null)
- live.Add(image);
- }
+ // The line's own list, not a column scan. Scanning both costs more and undercounts:
+ // a column covered by two overlapping runs reports only the first, so the second
+ // could be doomed while still on screen.
+ foreach (var image in line.Images)
+ live.Add(image);
}
}
}
@@ -570,27 +569,13 @@ private static void DropImages(Buffer.TerminalBuffer buffer, HashSet