Terminal graphics in Elixir for generating visualizations — charts, graphs, lines — and emitting them through whichever image protocol the terminal supports. Pure Elixir, with optional native helpers for cells and deflate that are built when a C compiler is present; no image decoding (you draw the pixels), no supervision tree.
Installation
def deps do
[
{:french_curve, "~> 0.2"}
]
endNothing to start and nothing to configure — every module is a pure function over values.
Requirements
makeanderlonPATHat build time: compiling runsmakethroughelixir_make(on Windows,nmakerunsMakefile.win)- A C compiler, optional: builds the helper that splits each cell's colours for
FrenchCurve.Backend.Cells; without one that backend runs in Elixir - libdeflate, optional, found through
pkg-config: compresses kitty payloads faster than:zlib, whichFrenchCurve.Deflateuses without it
Pipeline
data ──▶ Curve (smoothing) ──▶ Draw ──▶ Raster (RGBA buffer) ──▶ Backend ──▶ terminalA single Raster is the buffer every backend consumes; the backends only differ in
how they serialize it.
Backends
| Backend | Output | Terminals |
|---|---|---|
:braille | Unicode 2×4 dot cells (text) | Every terminal (incl. Ghostty, Alacritty, tmux, ssh) |
:cells | Block characters, two colours per cell, in a glyph set: :half (1×2), :quad (2×2), :sextant (2×3, Unicode 13), :braille (2×4 dots), :octant (2×4, Unicode 16) | Every terminal for :half; the finer sets need a font or terminal that draws them (Ghostty and kitty draw octants themselves; iTerm2 draws sextants, octants with Cascadia Mono 2404.23+) |
:kitty | Kitty graphics protocol, raw RGBA (f=32) | Kitty, Ghostty, WezTerm, Konsole |
:sixel | Sixel (palette-based, RLE) | iTerm2, WezTerm, foot, Konsole, xterm |
:iterm2 | iTerm2 inline image (PNG via :zlib) | iTerm2, WezTerm |
:braille returns cell rows ([[{grapheme, rgb_or_nil}]]), :cells returns
[[{grapheme, fg_rgb, bg_rgb}]]; the pixel backends return an escape-sequence string.
:cells is never detected — name it, with set:; FrenchCurve.Backend.Cells.default_set/1
suggests a set for a terminal.
Usage
raster =
FrenchCurve.Raster.new(120, 48)
|> FrenchCurve.Draw.polyline([{0, 47}, {40, 10}, {80, 30}, {119, 0}], {0, 200, 255, 255})
# explicit protocol
FrenchCurve.render(raster, :kitty, [])
# auto-detect from the environment
FrenchCurve.render(raster)From data
[3, 1, 4, 1, 5, 9, 2, 6]
|> FrenchCurve.Chart.line(width: 120, height: 48, color: {0, 200, 255, 255}, smooth: true)
|> FrenchCurve.render(:braille)
|> FrenchCurve.Backend.Braille.to_text()
|> Enum.each(&IO.puts/1)Try it
The examples run from a clone of https://github.com/jaman/french_curve; they are not
in the Hex package. render_demo.exs draws smoothed sine/cosine curves through every
backend, labelled; auto detects this terminal and renders that one; a backend name
(kitty, sixel, iterm2, braille) renders that one alone:
mix run examples/render_demo.exs
mix run examples/render_demo.exs auto
mix run examples/render_demo.exs kitty
area_demo.exs draws a filled area chart the same way, and animated_area.exs a
live scrolling area chart, in braille by default, through a pixel backend when one is
named, stopping after a frame count when one is given:
mix run examples/area_demo.exs
mix run examples/area_demo.exs sixel
mix run examples/animated_area.exs
mix run examples/animated_area.exs sixel
mix run examples/animated_area.exs sixel 40
to_terminal/3 returns a printable binary for any backend (braille cells are
converted to ANSI; pixel backends pass through), so swapping backends needs no
other change.
braille renders in any terminal; kitty/sixel/iterm2 only display where the
terminal supports that protocol (elsewhere you'll see raw escape bytes).
Redrawing a region
A picture drawn once is to_terminal/3. A region drawn over and over — a meter, a scope,
anything animated — is FrenchCurve.frame/3, which returns {paint, clear, place}:
{paint, clear, place} = FrenchCurve.frame(raster, :meter, fit: {40, 8})
IO.write(paint) # draw it
IO.write(place || paint) # draw it again without resending the pixels
IO.write(clear) # take it offid (:meter above) names the region across frames — any term, hashed to a protocol image
id — so each frame replaces the last instead of piling up. place is nil where the
protocol has no stored image, and redrawing then means sending paint again. frame/3
returns nil outright for :braille, which is the caller's cue to draw text.
Two things it settles that a caller cannot see from Capability.detect/0. First, a kitty
terminal may or may not implement stored images and placements, and one that does not draws
nothing when sent a store followed by a placement — frame/3 asks Capability.placements?/0
and picks the dialect. Second, the image is always named, so clear really removes it: a
kitty image is an overlay rather than cells, so text drawn over it does not rub it out and
leaving the alternate screen does not discard it. An unnamed one cannot be taken off at all,
and is still on the screen after the program has exited.
Positioning stays the host's job either way: frame/3 draws at the cursor, so put the
cursor where you want the region first. examples/animated_area.exs does exactly this —
cursor-home, then paint — across all four protocols.
Embedding in a host application (a sub-region)
FrenchCurve never owns the screen or the cursor. A host (e.g. a TUI framework) decides
where a chart goes — a cell region like full-width-from-row-N, {r1c1..r3c5}, a flex
slot — positions the cursor there, and writes the bytes FrenchCurve returns. FrenchCurve's
only spatial responsibility is sizing the output to fit a cell box.
Sizing is hybrid by protocol:
| Protocol | How it fits a {cols, rows} cell box |
|---|---|
:kitty | pass fit: {cols, rows} — the terminal scales the image; no cell-pixel size needed |
:iterm2 | pass fit: {cols, rows} — sized in cells natively; no cell-pixel size needed |
:sixel | render the raster at cols*cell_w × rows*cell_h pixels (needs cell-pixel size) |
:braille | render the raster at cols*2 × rows*4 (braille's fixed cell, no detection) |
# kitty / iterm2 — terminal scales to the cell box:
raster = FrenchCurve.Chart.line(data, width: 400, height: 200, smooth: true)
FrenchCurve.render(raster, :kitty, fit: {cols, rows})
# sixel — size the raster to the region; detect cell-pixel size if the host can:
cell_px =
case FrenchCurve.Geometry.detect_cell_size(&host_query_tty/1) do
{:ok, px} -> px
:error -> :unknown
end
{w, h} = FrenchCurve.Geometry.pixels_for_cells({cols, rows}, cell_px)
data |> FrenchCurve.Chart.line(width: w, height: h) |> FrenchCurve.render(:sixel, [])Cell-pixel detection stays in the host. FrenchCurve gives you the query bytes
(Geometry.cell_size_query/0) and the parser (Geometry.parse_cell_size/1), or a one-call
Geometry.detect_cell_size/1 that takes your IO function (fn query -> write+read tty end).
The host owns the tty, raw mode, and timeout; FrenchCurve never reads or writes it.
Sprites (tiles, tokens, game grids)
A Sprite is a Raster with a stable, content-addressed id: author a tile once, stamp
it across a grid as many times as needed. On kitty that is the protocol's own model —
upload once, place many by reference, with z-index layers.
tree = FrenchCurve.Sprite.new(tree_raster, id: 2)
# upload once (kitty stores it; other backends return "" — nothing to pre-upload)
{registry, bytes} = FrenchCurve.Sprite.Registry.ensure(FrenchCurve.Sprite.Registry.new(), tree, :kitty)
IO.write(bytes)
# the host positions the cursor, then places — as many times as you like
IO.write("\e[#{line};#{col}H")
IO.write(FrenchCurve.Sprite.place(tree, :kitty, fit: {4, 2}, z: 1, placement: 17))| Backend | place/3 realizes a placement as | upload/delete |
|---|---|---|
:kitty | a reference to the uploaded image (a=p, with z= layering, c=/r= cell box) — cheapest, flat cost per placement | transmit once / delete a placement |
:sixel / :iterm2 | re-emit the sprite's pixels (cost grows with placement count) | no-op |
:braille | rasterize to colored cells (low fidelity fallback) | no-op |
Position and z-ordering for non-kitty backends are the host's job (cursor placement +
emission order = painter's algorithm). kitty's z= does true layering.
Sixel has no alpha. Transparent pixels rely on P2=1, which many decoders (iTerm2
included) ignore — they flood unset pixels with the last palette color. For sixel,
composite onto a solid backdrop instead: place(sprite, :sixel, background: {r, g, b})
fills transparent pixels with that color, so a sprite reads correctly over a known
background. kitty and iterm2 use true RGBA and keep real transparency with no backdrop.
mix run examples/sprites_demo.exs kitty # a floor/tree/player scene; kitty | sixel | iterm2
Loading sprites from a catalog
Raster.from_rgba/4 is the pure inverse of to_rgba_binary/1 — load authored art with
no image decoder. Store a catalog as raw RGBA + dims (optionally zlib-compressed for
download; :zlib is built into OTP), then inflate and load locally:
{w, h, packed} = fetch_from_catalog("tree")
rgba = :zlib.uncompress(packed)
tree = w |> FrenchCurve.Raster.from_rgba(h, rgba) |> FrenchCurve.Sprite.new()fetch_from_catalog/1 is the host's own transport, and the :zlib.uncompress/1 step
applies only when the catalog was compressed.
Compression (kitty)
compress: true deflates the pixels before base64 and tells the terminal with o=z:
FrenchCurve.render(raster, :kitty, compress: true, fit: {cols, rows})
FrenchCurve.Backend.Kitty.transmit(raster, id, compress: true)
FrenchCurve.Sprite.upload(sprite, :kitty, compress: true)Off by default: the protocol's compression is optional, so a partial implementation may not have it. Turn it on when you know what you are drawing to.
Anything drawn rather than photographed — a chart, a tilemap, a sprite sheet — compresses heavily; a photograph compresses much less.
Capability detection
FrenchCurve.Capability.detect/0 picks a protocol from KITTY_WINDOW_ID, TERM,
TERM_PROGRAM, WEZTERM_PANE, LC_TERMINAL and KONSOLE_VERSION, preferring
kitty > iterm2 > sixel > braille, and caches the result in :persistent_term for the
life of the VM. detect/1 takes an environment map instead and does not cache — that is
the one to use for an ssh or multiplexer session, whose environment is not the host's.
Pass an explicit protocol: to override either.
iTerm2 is detected as :iterm2, its own inline-image protocol, at every version, even
though iTerm2 3.5 and later also answers the kitty query: a kitty image is an overlay
rather than cells, and removing one takes a delete addressed to its id, which iTerm2 does
not implement, while its inline images scroll and disappear with the screen like text.
Setting FRENCH_CURVE_ITERM2=kitty overrides this and sends iTerm2 kitty frames.
Animated images of either kind flicker in iTerm2 while its GPU renderer is on and draw
smoothly with GPU rendering off (Settings → General → Magic).
Asking the terminal instead
Environment variables are a guess. Capability.probe/0 returns the bytes that ask the
terminal directly (XTVERSION plus primary device attributes); write them, read until
probe_complete?/1 is true or your timeout expires, then hand the replies to
from_probe/1. read_until/1 below is the host program's own function over its tty:
IO.write(FrenchCurve.Capability.probe())
replies = read_until(&FrenchCurve.Capability.probe_complete?/1)
protocol = FrenchCurve.Capability.from_probe(replies) || FrenchCurve.Capability.detect()from_probe/1 returns nil when the replies name nothing usable, and a terminal that
answers neither query says nothing at all — so the caller owns the timeout, exactly as it
owns the tty.
Capability.placements?/0 is a separate question from the protocol: a terminal can speak
kitty's transmit-and-display without keeping images under an id, and sending such a
terminal a store followed by a placement draws nothing at all. FrenchCurve.frame/3 asks
this for you.