Skip to content

feat: add Sixel graphics protocol support#18

Merged
bahdotsh merged 4 commits into
mainfrom
feat/sixel-support
Mar 18, 2026
Merged

feat: add Sixel graphics protocol support#18
bahdotsh merged 4 commits into
mainfrom
feat/sixel-support

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

Summary

Closes #1

Adds Sixel image protocol support as a middle tier between iTerm2 and the Unicode half-block fallback. Terminals like foot, mlterm, mintty, and contour can now render actual pixel-level images instead of the half-block approximation.

  • Zero new dependencies — Sixel encoding implemented in-house (median-cut quantization to 256 colors, 6-row band encoding with RLE compression)
  • Auto-detection for known Sixel terminals via TERM_PROGRAM / TERM env vars
  • Manual override via MDTERM_IMAGE_PROTOCOL=sixel for terminals not auto-detected
  • Block rendering with crop caching for smooth scrolling (same pattern as iTerm2 path)
  • 8 new unit tests covering encoding, RLE, and quantization

Protocol priority

Kitty > iTerm2 > Sixel > HalfBlock

Terminals that support multiple protocols (e.g. WezTerm supports both Kitty and Sixel) will use the most efficient one.

Auto-detected Sixel terminals

Terminal Detection method
foot TERM_PROGRAM=foot or TERM=foot/foot-extra
mlterm TERM_PROGRAM=mlterm or MLTERM env var
mintty TERM_PROGRAM=mintty
contour TERM_PROGRAM=contour

Test plan

  • cargo check — compiles clean
  • cargo clippy — zero warnings
  • cargo test — all 117 tests pass (109 existing + 8 new)
  • cargo build --release — release build succeeds
  • Manual test on a Sixel-capable terminal (e.g. MDTERM_IMAGE_PROTOCOL=sixel mdterm README.md)

Images on terminals that don't support Kitty or iTerm2 were falling
back to the Unicode half-block renderer, which — let's be honest —
produces results that look like someone described the image to a
particularly literal ASCII artist. Terminals like foot, mlterm,
mintty, and contour all support Sixel, which renders *actual pixels*.

The implementation is zero new dependencies. Sixel encoding is done
in-house: median-cut colour quantization to 256 colours, 6-row band
encoding with RLE compression, the whole DCS-to-ST dance. Images are
pre-encoded during pre_render() and cached, with crop caching for
smooth scrolling (same pattern as the iTerm2 path).

Terminal detection auto-identifies foot, mlterm, mintty, and contour
via TERM_PROGRAM / TERM env vars. For everything else, users can
force the protocol with MDTERM_IMAGE_PROTOCOL=sixel — because
sometimes you just know better than the heuristics.

Rendering follows the iTerm2 block-overlay approach: skip image rows
in the main pass, then emit one Sixel sequence per visible image
region in a second pass. This keeps scrolling smooth and avoids
re-emitting colour registers for every terminal row.
The sixel encoder was blending alpha against hardcoded black, which
means transparent PNGs look *terrible* on light-themed terminals.
The correct thing is to blend against the actual theme background,
just like every other sane compositing pipeline does.

While at it, sixel_quantize() was accepting a `width` parameter it
never used, with a `let _ = width` to shut the compiler up. That's
not how you fix unused parameters — you *remove* them.

Also unified the iTerm2/Sixel block rendering dispatch in the viewer.
Having a 15-line if/else that calls two identical-signature methods
with identical arguments is the kind of copy-paste that breeds bugs.
render_block_image() dispatches internally now.
The median-cut quantizer in sixel_median_cut could divide by zero
if split_off ever produced an empty box — the weighted average fold
would happily compute total=0 and then try to divide by it. Not
currently reachable through encode_sixel (which bails on empty
images), but it was a latent trap waiting for someone to call the
function from a different path. Filter out empty boxes before the
average computation.

The crop path in render_sixel_block had a similar problem: if
first_row * cell_h_px exceeded the resized image height, crop_imm
would get a y coordinate past the end of the image. Clamp y to
height-1 so we always produce a valid crop region.

While at it, add a test for alpha blending — a 1×1 half-transparent
red pixel against white should produce roughly (100%, 50%, 50%) in
the Sixel color register. The kind of thing that should have been
tested from the start.
A few lines in the sixel encoder and its tests were too long for
rustfmt's liking. This is purely whitespace — no logic changes.
@bahdotsh bahdotsh merged commit da858a8 into main Mar 18, 2026
5 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.

Sixel's support

1 participant