Skip to content

A Sixel replaces the Sixel runs it lands on - #175

Merged
tomlm merged 1 commit into
mainfrom
sixel-replaces-covered-runs
Oct 13, 2026
Merged

tomlm merged 1 commit into
mainfrom
sixel-replaces-covered-runs

Conversation

@tomlm

@tomlm tomlm commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Problem

PlaceImage added a run to each line a Sixel covered but never removed the Sixel runs already under it. Printing a character over a picture correctly splits and replaces that part of it; a picture over a picture did not.

An application that repaints small regions with Sixels (Avalonia.SixelBuffer sends a tiny sixel for every cursor move and hover change) therefore left every earlier picture piece stacked on the line. In the alternate screen, lines never die, so the per-line run list, the retained TerminalImages, the view's per-image bitmaps, and the per-frame draw work all grew without bound. Avalonia.Terminal got slower by the minute; Windows Terminal, which replaces covered cells, did not.

Fix

Before a Sixel run is added to a line, the Sixel runs over its columns are split with SplitPlacementsOver, exactly as a printed character does. Fully covered runs disappear; partially covered ones keep their uncovered fragments. Kitty placements are overlays ordered by z-index with their own delete commands and are left alone.

Semantic note

A Sixel sent with background select 1 (transparent unset pixels) now replaces what it covers rather than showing the older picture through its holes. The DEC behaviour would composite; this is consistent with XTerm.NET already treating Sixel as content that printing replaces.

Tests

Three new cases in ImageCellLifetimeTests: the same picture drawn twice leaves one run and one image per row; a one-cell picture over part of a wider one keeps the uncovered fragment; fifty repaints of one cell leave two runs per row rather than fifty-two. Full suite: 2301 tests pass.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Perf comparison — this change, against its base

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.38 → 3.40 +0.6% ±2% 7%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.97 → 8.98 +0.1% ±2% 6%
truecolor 0.00 → 0.00 0.00 → 0.00 9.94 → 9.97 +0.4% ±1% 4%
alt-redraw 0.00 → 0.00 0.00 → 0.00 14.34 → 14.46 +0.9% ±2% 5%
unicode 7.66 → 7.66 0.45 → 0.45 35.11 → 35.22 +0.3% ±9% 28%
flood 0.00 → 0.00 0.00 → 0.00 95.41 → 96.46 +1.1% ±1% 4%

Each corpus is gated at max(4%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.7.0 mvid:41656e21-75ec-4936-b683-6fb2b0384560
  • head: XTerm.NET 2.0.7.0 mvid:96b05f35-feca-4e32-a9a0-26f1c69dae29

Perf comparison — cumulative, everything since 2.0.7

3 run(s) of each side, alternating on one machine. Allocation is a count and is gated exactly. Time is a measurement, so its gate is derived from the spread this job just observed in itself rather than fixed in advance.

corpus bytes/char gen0/Mchar ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 0.00 → 0.00 3.39 → 3.40 +0.3% ±2% 5%
sgr-churn 0.00 → 0.00 0.00 → 0.00 8.98 → 8.98 +0.0% ±3% 9%
truecolor 0.00 → 0.00 0.00 → 0.00 9.94 → 9.97 +0.3% ±3% 10%
alt-redraw 0.00 → 0.00 0.00 → 0.00 14.51 → 14.46 -0.4% ±2% 5%
unicode 7.66 → 7.66 0.45 → 0.45 35.50 → 35.22 -0.8% ±2% 6%
flood 0.00 → 0.00 0.00 → 0.00 95.95 → 96.46 +0.5% ±1% 5%

Each corpus is gated at max(5%, 3 × its own noise). A wide noise column means this runner was busy and the timing half of the table should be read as advisory; the allocation half is exact either way.

assemblies measured
  • base: XTerm.NET 2.0.7.0 mvid:2609fe33-3671-4fb3-9e45-c3e43c231eb3
  • head: XTerm.NET 2.0.7.0 mvid:96b05f35-feca-4e32-a9a0-26f1c69dae29

@tomlm
tomlm merged commit 3d845e8 into main Oct 13, 2026
4 checks passed
@tomlm
tomlm deleted the sixel-replaces-covered-runs branch October 13, 2026 23:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant