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.

Hex.pm Docs

Installation

def deps do
  [
    {:french_curve, "~> 0.2"}
  ]
end

Nothing to start and nothing to configure — every module is a pure function over values.

Requirements

  • make and erl on PATH at build time: compiling runs make through elixir_make (on Windows, nmake runs Makefile.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, which FrenchCurve.Deflate uses without it

Pipeline

data ──▶ Curve (smoothing) ──▶ Draw ──▶ Raster (RGBA buffer) ──▶ Backend ──▶ terminal

A single Raster is the buffer every backend consumes; the backends only differ in how they serialize it.

Backends

BackendOutputTerminals
:brailleUnicode 2×4 dot cells (text)Every terminal (incl. Ghostty, Alacritty, tmux, ssh)
:cellsBlock 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+)
:kittyKitty graphics protocol, raw RGBA (f=32)Kitty, Ghostty, WezTerm, Konsole
:sixelSixel (palette-based, RLE)iTerm2, WezTerm, foot, Konsole, xterm
:iterm2iTerm2 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 off

id (: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:

ProtocolHow it fits a {cols, rows} cell box
:kittypass fit: {cols, rows} — the terminal scales the image; no cell-pixel size needed
:iterm2pass fit: {cols, rows} — sized in cells natively; no cell-pixel size needed
:sixelrender the raster at cols*cell_w × rows*cell_h pixels (needs cell-pixel size)
:braillerender 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))
Backendplace/3 realizes a placement asupload/delete
:kittya reference to the uploaded image (a=p, with z= layering, c=/r= cell box) — cheapest, flat cost per placementtransmit once / delete a placement
:sixel / :iterm2re-emit the sprite's pixels (cost grows with placement count)no-op
:braillerasterize 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.