Skip to content

Hold Sixel as the pixels each line shows (3.0.0) - #176

Merged
tomlm merged 2 commits into
mainfrom
sixel-strips
Oct 14, 2026
Merged

tomlm merged 2 commits into
mainfrom
sixel-strips

Conversation

@tomlm

@tomlm tomlm commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Why

A Sixel is content: it paints the cells it lands on, a later Sixel paints over it, and printing over a cell replaces it. 2.x kept every Sixel as a picture — a TerminalImage plus one LinePlacement per row — and split it wherever something covered part of it. A program that repaints small regions (a cursor, a hover, a diffing framebuffer such as SixelBuffer) left a patchwork behind.

Measured in Avalonia.Terminal at 307×77 running SixelBuffer's ControlCatalog in Sixel mode:

pictures on screen paint per frame
2.0.7 13,551 images, 58 MB 593 ms
2.0.8 (#175) 2,606 images, 23 MB 110 ms
this PR 76 strips, 11 MB 0.6 ms

What changes

  • SixelStrip (new, public, read-only from outside): the Sixel pixels one line shows, one cell tall, BGRA straight alpha, with CellWidth/CellHeight, PaintedColumns, HasPixelsAt(col) and a Version that moves after every change. BufferLine.Sixel exposes it.
  • Painting: PlaceImage keeps its cursor, DECSDM, scroll and mode-8452 behaviour, but for Sixel it paints each row into the line's strip. Opaque pixels replace, unset pixels (background select 1) leave what was there, partial alpha (inline PNG) blends.
  • Erasing: SetCell, SetSingleWidthRun and Fill clear the cells' pixels; a strip with nothing left is dropped.
  • Moving: CopyCellsFrom moves pixels with their cells, so ICH/DCH, insert mode and margin scrolls no longer leave Sixel behind.
  • Copies: Clone/CopyFrom share the strip until either side writes (copy-on-write), so frame capture costs a reference.
  • Rescaling: a Sixel laid out against a new cell size resamples the strip first, so one strip has one scale.
  • Budget: MaxImageBytes counts strips; over budget, scrollback strips go first, oldest line first; what is on screen is never taken.
  • iTerm2 inline images are painted into strips the same way.
  • Kitty is unchanged. Kitty position/z deletes no longer touch Sixel (they never should have).
  • Terminal.SixelPlaced (internal) lets tests see the decoded picture, which the buffer no longer keeps.

Breaking (3.0.0)

Sixel no longer appears in line.Placements / line.Images; TryGetPlacementAt / TryGetImageAt find Kitty pictures only; PlacementKind.Sixel is never created (the value stays so comparisons compile). A 2.x renderer keeps compiling and silently draws no Sixel, hence the major version. README has an "Upgrading to 3.0" section with the renderer change.

Tests

2,303 pass. The Sixel placement and lifetime tests now assert pixels (a striped picture checks which band landed on which row); new tests cover ICH/DCH moving pixels, transparent pixels showing through, repainting 50 times without growth, copy-on-write, cell-size rescaling, and the budget rules.

The renderer side is in Avalonia.Terminal (separate PR), verified pixel-identical between its Skia and classic paths.

🤖 Generated with Claude Code

A Sixel is content: it paints the cells it lands on, a later Sixel paints
over it, and printing over a cell replaces it. Keeping each Sixel as a
picture with one placement per row, split wherever something covered part
of it, left a program that repaints small regions with thousands of
fragments per screen, each pinning its whole picture and each its own draw.

Each line now keeps a SixelStrip: the pixels Sixel painted on it, one cell
tall. A Sixel paints into the strips of the lines it covers (unset pixels
under background select 1 leave what was there), printing and erasing clear
those cells, and ICH/DCH, insert mode and margin scrolls move the pixels
with their cells. Clones share a strip until either side writes. The image
budget counts strips and drops scrollback strips first, oldest line first,
never what is on screen. iTerm2 inline images are painted the same way.

Kitty is unchanged. Sixel no longer appears in Placements or Images, so
this is a major version: 3.0.0. README has an upgrade section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 14, 2026 •

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.40 → 3.46 +1.8% ±3% 8%
sgr-churn 0.00 → 0.00 0.00 → 0.00 9.10 → 9.06 -0.5% ±1% 4%
truecolor 0.00 → 0.00 0.00 → 0.00 10.25 → 9.98 -2.6% ±3% 8%
alt-redraw 0.00 → 0.00 0.00 → 0.00 14.50 → 14.54 +0.3% ±3% 10%
unicode 7.66 → 7.66 0.45 → 0.45 34.92 → 35.95 +2.9% ±3% 10%
flood 0.00 → 0.00 0.00 → 0.00 96.08 → 96.05 -0.0% ±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.8.0 mvid:4d610f86-7357-4b04-a33a-2422bab70f0b
  • head: XTerm.NET 3.0.0.0 mvid:7d82d65d-e009-4cdd-9e95-353f6fd4194a

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.40 → 3.46 +1.8% ±3% 8%
sgr-churn 0.00 → 0.00 0.00 → 0.00 9.18 → 9.06 -1.3% ±1% 5%
truecolor 0.00 → 0.00 0.00 → 0.00 10.30 → 9.98 -3.1% ±2% 7%
alt-redraw 0.00 → 0.00 0.00 → 0.00 14.46 → 14.54 +0.5% ±2% 6%
unicode 7.66 → 7.66 0.45 → 0.45 35.90 → 35.95 +0.1% ±2% 6%
flood 0.00 → 0.00 0.00 → 0.00 96.33 → 96.05 -0.3% ±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 3.0.0.0 mvid:7d82d65d-e009-4cdd-9e95-353f6fd4194a

The images section now gives the whole draw order for a row (backgrounds,
Kitty behind, text, the Sixel strip, Kitty in front) with code, how to key
uploads (TerminalImage + FrameSerial, SixelStrip + Version, and the torn
read), mapping cells onto the grid after a font or DPI change, and the
per-column lookups including Sixel. It also says to call
NotifyTextAreaPixelsChanged after changing the cell size: without it an
application sizing pictures from the cell size keeps drawing at the old
one after a move to another monitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomlm
tomlm merged commit f6b9bbb into main Oct 14, 2026
4 checks passed
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