From f692de2b956a973186274683f4ecf8a70b39df36 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Tue, 13 Oct 2026 16:25:14 -0700 Subject: [PATCH] A Sixel replaces the Sixel runs it lands on Placing a Sixel added a run to each line it covered but never removed the Sixel runs already under it. Printing a character over a picture correctly splits and replaces that part; a picture over a picture did not. Every small repaint an application sent (a cursor move, a hover highlight) left the earlier pieces stacked on the line, and in the alternate screen lines never die, so the per-line run list, the retained images and the per-frame draw work all grew without bound. Avalonia.Terminal got slower by the minute under Avalonia.SixelBuffer; Windows Terminal, which replaces covered cells, did not. A Sixel run now splits the Sixel runs over its columns before it is added, exactly as a printed character does: fully covered runs go, partially covered ones keep their uncovered fragments. Kitty placements are overlays ordered by z-index with their own delete commands and are left alone. A Sixel with background select 1 (transparent unset pixels) therefore replaces what it covers rather than showing the older picture through its holes, which is consistent with treating Sixel as content. Tests: the same picture drawn twice leaves one run and one image per row; a narrow picture over part of a wider one keeps the uncovered fragment; fifty repaints of one cell leave two runs per row. Co-Authored-By: Claude Fable 5.1 --- .../Graphics/ImageCellLifetimeTests.cs | 56 +++++++++++++++++++ src/XTerm.NET/InputHandler.KittyGraphics.cs | 9 +++ 2 files changed, 65 insertions(+) diff --git a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs index 9d6ccea..9263357 100644 --- a/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs +++ b/src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs @@ -60,6 +60,62 @@ private static int ImageCellCount(Terminal terminal) return count; } + [Fact] + public void Sixel_over_a_sixel_replaces_the_runs_it_covers() + { + var terminal = Fresh(); + WriteSixel(terminal); + var first = ImageAssertions.ImageAt(terminal, 0, 0); + + // The same picture drawn again at the same place: one run per row, not two + terminal.Write($"{Esc}[1;1H"); + WriteSixel(terminal); + + var line = terminal.Buffer.Lines[terminal.Buffer.YBase]!; + Assert.Single(line.Placements); + Assert.Single(line.Images); + Assert.NotSame(first, ImageAssertions.ImageAt(terminal, 0, 0)); + Assert.Equal(8, ImageCellCount(terminal)); + } + + [Fact] + public void Sixel_over_part_of_a_sixel_keeps_the_rest_of_it() + { + var terminal = Fresh(); + WriteSixel(terminal); + var first = ImageAssertions.ImageAt(terminal, 0, 0); + + // A one-cell-wide picture on the second column, over the right half of the first one + terminal.Write($"{Esc}[1;2H"); + WriteSixel(terminal, "#0;2;0;100;0!2~-!2~"); + + var line = terminal.Buffer.Lines[terminal.Buffer.YBase]!; + Assert.Equal(2, line.Placements.Count); + Assert.Same(first, ImageAssertions.ImageAt(terminal, 0, 0)); + Assert.NotSame(first, ImageAssertions.ImageAt(terminal, 1, 0)); + Assert.Equal(8, ImageCellCount(terminal)); + } + + [Fact] + public void Repainting_a_cell_many_times_does_not_accumulate_runs() + { + var terminal = Fresh(); + WriteSixel(terminal); + + for (int i = 0; i < 50; i++) + { + terminal.Write($"{Esc}[1;2H"); + WriteSixel(terminal, "#0;2;0;0;100!2~-!2~"); + } + + for (int row = 0; row < 4; row++) + { + var line = terminal.Buffer.Lines[terminal.Buffer.YBase + row]!; + Assert.Equal(2, line.Placements.Count); + Assert.Equal(2, line.Images.Count); + } + } + [Fact] public void Printing_over_a_tile_replaces_it_with_the_character() { diff --git a/src/XTerm.NET/InputHandler.KittyGraphics.cs b/src/XTerm.NET/InputHandler.KittyGraphics.cs index fc42b94..35a6f1e 100644 --- a/src/XTerm.NET/InputHandler.KittyGraphics.cs +++ b/src/XTerm.NET/InputHandler.KittyGraphics.cs @@ -1040,6 +1040,15 @@ private void PlaceImage(Graphics.ImagePlacement placement, if (line is null) break; + // Sixel is content, so a picture landing on cells that already show one replaces that + // part of it, exactly as printing a character there does. Without this every Sixel a + // program draws over an earlier one is kept beneath it: a line in the alternate screen + // never dies, so an application repainting small regions (a cursor, a hover highlight) + // grew an unbounded stack of runs per line, each drawn every frame and each pinning its + // picture in memory. Kitty placements are overlays ordered by z-index and are left alone. + if (kind == Graphics.PlacementKind.Sixel) + line.SplitPlacementsOver(startCol, placement.Cols); + // One run per line. 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.