malevich

Look up

Real pixels

Sixel, kitty, and iTerm2 panels, with the text chrome left around them.

The ladder's top rung (feature pixel). Where the terminal speaks an image protocol, the plot panel is a real image, and title, axes, and legend stay crisp text cells. The result is still a deterministic String.

The output is hybrid on purpose. Malevich owns no font rasterizer. That is the small-dependency budget, so text stays the terminal's job, as in cell rendering, and only the plot rectangle becomes pixels. Marks rasterize at device-pixel resolution through the same pipeline: M4 buckets per pixel column, heatmaps sample per pixel. The undrawn panel stays transparent to the terminal background.

Turn it on#

Loss curves, a calendar time axis, and smoothing: cell rendering beside pixel rendering

A 2D density, contour lines, and a vector field: cell rendering beside pixel rendering

The showcase in a kitty terminal. Every chart twice: cells on the left, the same plot value as a real image on the right. Title, axes, and legend stay text. Only the plot rectangle becomes pixels.

cargo add malevich --features pixel
cargo run --example showcase --features pixel   # every chart, cells beside pixels

One call, stdout, the best tier the terminal offers:

println!("{}", plot.render_best(&frame));

The explicit, pure path — detect once, render many:

let caps = malevich::pixel::Capabilities::detect_for(&std::io::stdout());
println!("{}", plot.render_with_capabilities(&frame, &caps));

Capabilities is a plain value. Log it, cache it, override it. When it offers no protocol, every render falls back to cells. There is no "unsupported terminal" error anywhere.

Protocols#

protocolwhat it isstanding
Kittyraw RGBA with alphathe most capable
SixelDEC 1987, palette-bandedthe most widely spoken
ITerm2an inline PNG pinned to the panel's cell boxiTerm2

Encoders are hand-rolled and dependency-free. Each is a thin layer over the shared pixel panel.

Detection: sniff and probe#

Two tiers with different licenses:

  • Sniffing reads environment variables. It is free, instant, and wrong only by omission. It may run anywhere. It knows kitty (KITTY_WINDOW_ID, KITTY_PID, a kitty TERM), Ghostty (GHOSTTY_BIN_DIR, its TERM or TERM_PROGRAM), iTerm2 (TERM_PROGRAM, or LC_TERMINAL, which ssh forwards), WezTerm (TERM_PROGRAM or WEZTERM_EXECUTABLE), Rio, Warp, foot, mlterm, Konsole 22.04+, and Windows Terminal. TMUX, a screen or tmux TERM, dumb, and unknown mean cells. MALEVICH_GRAPHICS=kitty|sixel|iterm2|none outranks all of it, for the user who has arranged passthrough or wants cells.
  • Probing asks the terminal itself, one raw-mode /dev/tty round trip: the kitty graphics query, XTVERSION, XTSMGRAPHICS, and CSI 16 t for the cell size, with DA1 as the ordering barrier. It is ground truth that survives ssh, about 100 ms, once per process, and licensed only where writing escapes is safe: the actual output destination is a tty, no tmux or screen in between, TERM not dumb.

Capabilities::detect_for(&destination) keys the probe decision to the stream that will receive the plot. detect() is the stdout convenience. An unanswered probe is not evidence. It degrades to the sniff answer. The value records which tier answered (Source::Probed or Source::Sniffed). A host that already knows its terminal — a remote session's, a second window's — states the answer with Capabilities::new(protocols, cell_size) (Source::Declared) and skips detection.

Inside a ratatui app#

With both pixel and ratatui enabled, the same panels render inside a TUI. chart.widget().graphics(g) on a stateful widget reserves its area in the buffer — spaces, skipped, so ratatui's diff leaves the image alone — and stores the encoded block in the PlotState. The host emits it after terminal.draw with Graphics::present: one synchronized write, new kitty placements created before the old are retired. Probe capabilities before ratatui::init(). The query reads terminal replies a raw-mode event loop would swallow. Zoom, pan, crosshair, and snap keep working over the image. The chrome becomes anti-aliased marks drawn into the panel itself. The mechanics live in interaction.md. cargo run -p fred in a kitty/sixel/iTerm2 terminal is the live proof.

On a page#

Plot::to_svg

loss ── train ── val ── target 5 ┤ │ 4 ┤ │ l │ o 3 ┤ s │ s 2 ┤ < converging │ │ 1 ┤ │ 0 ┤ └┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬ 0 10 20 30 40 50 60 70 80 90 100 110 120 step

Plot::to_svg_pixels

loss ── train ── val ── target 5 ┤ │ 4 ┤ │ l │ o 3 ┤ s │ s 2 ┤ │ │ 1 ┤ │ 0 ┤ └┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬─────┬──────┬─────┬─────┬─────┬ 0 10 20 30 40 50 60 70 80 90 100 110 120 step
Plate 1. One plot value, two SVG cards. The chrome is the same cell card in both. The panel is quadrant cells in the first, and in the second the device-pixel raster a graphics terminal shows, the note at data coordinates in the pixel font.

The same hybrid, for a host that draws SVG. Chrome stays the cell card: rectangles and text the host's font draws. The panel is the device-pixel raster, one path of rectangles per color, resampled smoothly on any screen. There is no image element and no dependency. GitHub's sanitizer keeps rectangles and drops embedded images. Plot::to_svg stays the cell-grid card and needs no feature.

use malevich::pixel::{Graphics, Protocol};

let svg = plot.to_svg_pixels(&frame, &Graphics::new(Protocol::Kitty));

Graphics::protocol is terminal transport and does not change the bytes. Any protocol is fine. Cell size and stroke are the ones the panel uses, the same fields render_pixels reads. economical() is the caller's choice, as it is for a terminal. Default-colored marks take the card foreground, so an unlabeled series matches the axes. Named colors freeze to the same concrete RGB the pixel canvas uses. Ink centered on the fitted domain edge clips, as it does on the pixel canvas; widen the domain with x_max or y_max when the whole marker has to stay inside. A text-only plot, a zero cell size, and a frame with no panel degrade to Plot::to_svg. In-panel text marks use the pixel font. Axis text does not.

The size follows the ink, not the frame. A run of one color along a pixel row is one rectangle, and it grows down while the rows below repeat it. Bars and flat fills cost little; anti-aliased lines and translucent fills cost more. A smooth color field changes color at nearly every pixel and runs to megabytes. For that plot, Plot::to_svg or a smaller cell size is the better card.

Rough edges#

  • A multiplexer (tmux, screen) blocks probing by design. Sniffed answers still apply, and cells always work.
  • Font coverage is irrelevant here. Cell size in device pixels matters for sharpness. When the terminal will not report it, a documented fallback size is used.
  • The first probe is a real terminal round trip. Render paths never write queries.

The argument is Degradation is the contract and The frame is run state. Vocabulary is in terminology, under Graphics, Capabilities, and Protocol.