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