plotui
Interactive 2D/3D plots in the terminal — Plotly-style — for Textual, Ratatui, and Bubble Tea, powered by a Rust core and the Kitty graphics protocol.
plotui renders scatter plots (and, soon, lines / surfaces / bars) as real
pixel graphics inside a terminal, and lets you rotate, pan, and zoom them. It
drops into a Textual,
Ratatui, or
Bubble Tea app as a first-class
widget, with the rendering engine written in Rust so it stays fast in 2D and 3D.
Status: early scaffold. Working today: 2D scatter/line/step/bar, histogram, box, heatmap, and band charts with axes, ticks, titles, explicit ranges, log scales, categorical labels, a colorbar and a legend; DAG/pipeline graphs with a layered layout and a DOT subset reader; a 3D scatter/graph/surface/mesh engine; a Kitty-image raw demo; and a Textual widget.
Architecture
The one rule that shapes everything: the Rust core owns pixels, not the terminal. It has no event loop and no input handling — the TUI framework (Textual, Ratatui, or Bubble Tea) owns the loop, forwards input to the camera, and asks for a frame.
f64 data ─▶ camera + rasterizer ─▶ RGBA buffer ─▶ Kitty escape bytes ─▶ your terminal's cell grid
crates/
plotui-core/ pure engine: data model, 3D camera, rasterizer → RGBA
plotui-protocol/ RGBA → terminal bytes (Kitty graphics protocol)
plotui-term/ shared frontend glue: render-path detection, cell-pixel
probing, tmux passthrough, the per-frame render policy
plotui-bind/ shared binding semantics: parsing, validation, defaults,
and their exact error messages (Python and Go agree)
plotui-py/ PyO3 bindings → the `plotui._plotui` native module
plotui-ratatui/ Ratatui widget (native Rust frontend)
plotui-ffi/ C ABI (cdylib + staticlib) behind the Go bindings
python/plotui/ the Python package + Textual `PlotWidget`
go/ Go bindings + `teaplot`, the Bubble Tea v2 component
examples/ raw_demo.py (Kitty images), textual_demo.py
core and protocol are pure and I/O-free, so the same engine can back every
frontend and be unit-tested by hashing pixel buffers. The protocol layer emits
Kitty graphics escapes — chunked, placement-aware, and wrapped for tmux
passthrough — as pure functions of the RGBA frame.
Integrations
Each TUI framework gets a first-class widget, not a port. All frontends sit on
the same policy crates (plotui-term for detection/tmux/render policy,
plotui-bind for argument validation and its exact error strings), so a plot
looks and behaves identically whichever framework hosts it — down to the error
messages.
| Frontend | How it works | Where in the codebase | Try it |
|---|---|---|---|
| Textual (Python) | PlotWidget wraps the plotui._plotui native module (PyO3). Mouse events route to the camera, hover/click picking arrives as Textual messages, extend streams points in-place, and text overlays splice into the image without re-rasterizing. |
python/plotui/textual.py; native module in crates/plotui-py |
python examples/textual_graph.py |
| Ratatui (Rust) | A native StatefulWidget plus an app-owned PlotState: hand it crossterm events, draw it like any other widget — frames and Kitty placement ride ratatui's own buffer diff, flicker-free. |
crates/plotui-ratatui |
cargo run -p plotui-ratatui --example demo |
| Bubble Tea (Go) | teaplot.New(plot) returns an Elm-style model: Update consumes tea mouse/key events, View lays out the cell grid, and image escapes leave as tea.Raw commands. Links to the Rust engine statically over the plotui-ffi C ABI (cgo). |
go/ (bindings) + go/teaplot (component); ABI in crates/plotui-ffi |
go run ./examples/demo from go/ — see go/README.md |
| Browser (WASM) | The same engine compiled to WebAssembly drives the live demos on the website: pointer events feed the engine's own camera, and every frame is its RGBA bytes blitted onto a canvas. Not a plotting-in-the-browser product — it exists so the site can show the real renderer. | crates/plotui-wasm; consumed by site/ |
plotui.xyz/examples.html |
The three TUI widgets have feature parity: render-path detection, tmux passthrough, drag/zoom/pan/keys, picking + hover, the 2D crosshair, text overlays, half-resolution interaction frames, and streaming extend.
Install the CLI
plotui is also a command-line tool: pipe columns of numbers in, get a
real-pixel chart out — interactive on a TTY (pan, zoom, crosshair), a single
printed frame when piped or with --static.
curl -fsSL https://plotui.xyz/install.sh | sh # prebuilt binary
brew install sebaheg/tap/plotui # Homebrew (macOS / Linux)
pip install plotui # prebuilt wheel: the library + the CLI
cargo install plotui # build from source
cargo binstall plotui # prebuilt, via cargo-binstall
seq 1 100 | LC_ALL=C awk '{print $1, sin($1/10)}' | plotui line
plotui scatter -H -d, data.csv # header row + comma-delimited
plotui bar counts.tsv # --horizontal, --stack, --group
plotui step states.txt # holds its value between samples
plotui hist samples.txt # binned automatically
plotui box -H measurements.tsv # one box per column
plotui dag pipeline.dot # a DAG from a DOT file; hover a task
# to light everything it waits on
plotui line --log-y --title "queue depth" \
--x-title minute --y-title items # titles and log scales
plotui line --x-range 0:100 --y-range 0:1 # pin an extent, LO:HI
tail -f app.log | LC_ALL=C awk '{print $2}' \
| plotui line -f --window 200 # live, on the last 200 samples
plotui example scatter # built-in demo scenes, no data needed
plotui example deps # plotui's own dependency graph, laid
# out live by a force simulation
plotui example pipeline # a nightly forecast DAG, running
--follow (-f) keeps the reader open instead of plotting once at EOF: rows
are parsed as they arrive and appended in place, so the chart grows without a
redraw from scratch and pan/zoom survive. It needs piped input and a terminal
to draw into — a malformed line is skipped rather than fatal, and the count is
reported when you quit. The shape of the input (delimiter, column count,
whether x is a calendar) is settled by the first row and held for the rest of
the stream.
On a long feed the whole run compresses into a sliver, so --window <N> keeps
the view on the last N samples and --last <span> on the last 30s / 5m /
2h of x. Neither drops data: the window is a view, drawn on the range
slider (which a window switches on) against the entire run, so you can drag
back through everything that has arrived. Doing so hands the view over — a
reader who has scrolled back to an incident does not want the next row to
yank them forward — and f goes live again, jumping to the head.
Like every plotui frontend, the CLI needs a terminal with Kitty graphics (supported terminals below); elsewhere it prints a notice and exits.
Develop
Requires Rust and Python 3.9+. Build the native module into a virtualenv with maturin:
python -m venv .venv && source .venv/bin/activate
pip install maturin textual
maturin develop --release
Then, in a terminal with Kitty graphics support — Kitty, Ghostty, iTerm2 ≥ 3.5, WezTerm, or Konsole — for the full-resolution pixel demos:
python examples/raw_demo.py # 3D scatter via Kitty images
python examples/textual_demo.py # embedded in Textual
python examples/textual_graph.py # interactive graph: hover + click-to-inspect
The Textual widget picks its render path per terminal: Unicode-placeholder
Kitty graphics in Kitty/Ghostty, direct Kitty placement in iTerm2/WezTerm/
Konsole — plus Warp, Rio, and VS Code, whose younger Kitty decoders are
supported but still maturing (VS Code needs its
terminal.integrated.enableImages setting). plotui only draws
real pixels — terminals without Kitty graphics get a notice naming supported
terminals, never a degraded plot. Override with
PLOTUI_RENDER=placeholder|direct or PlotWidget(..., render_mode=...).
Python API
from plotui import Plot
# 2D: axes, ticks, and a legend appear automatically. Traces added without a
# color take colorway slots in fixed order; `name=` puts a series in the
# legend. Colors accept (r, g, b) tuples or shorthands: "#e63c78", "red".
plot = Plot()
plot.add_line(xs, ys, name="forecast")
plot.add_scatter(xs2, ys2, name="observed")
plot.add_bar(xs3, heights)
# Secondary axes: axis="y2"/"y3" bind a series to an independent right-hand
# axis — its own autoscale and tick column, labels tinted to the series color
# (y2 innermost, y3 outermost). The grid stays with the left axis.
plot.add_line(xs, tokens, name="tokens", axis="y2")
plot.add_line(xs, cpu_minutes, name="cpu min", axis="y3")
# Titles: each buys its own margin, so the plot area shrinks rather than
# drawing over the data. The y title is drawn rotated in the left margin.
plot.set_title("p99 latency")
plot.set_x_title("requests")
plot.set_y_title("ms")
# Ranges and scales: an explicit range pins the *extent* only — no autoscale
# padding, and zoom/pan still compose on top of it (unlike set_x_window,
# which is the live window and supersedes the camera). A log axis ticks in
# powers of ten; values at or below zero have no log coordinate and neither
# set the range nor draw.
plot.set_x_range((0, 100)) # None restores autoscale
plot.set_y_range((0.1, 1e4))
plot.set_y_log(True) # set_x_log for x
# DAGs and pipelines: labelled boxes wired by arrows, laid out by rank. Node
# centres are data coordinates; each box is sized in pixels from its label,
# so zooming spreads the graph apart while the text stays readable.
from plotui import LayeredLayout, from_dot, reachable
layout = LayeredLayout(len(tasks), edges) # rankdir="TB" or "LR"
plot = Plot()
h = plot.add_graph2d(*layout.positions(), edges,
labels=tasks, routes=layout.routes())
plot.set_graph_colors(h, states) # repaint as the run advances
lit = reachable(len(tasks), edges, hovered) # everything it waits on
plot = from_dot(open("pipeline.dot").read()) # or straight from DOT
# 3D: any 3D trace switches the plot to the orbit camera.
plot = Plot()
plot.add_scatter3d(xs, ys, zs, name="Cluster A") # colors from the colorway
# Colorways: the default sequence is pink/cyan/orange-first; swap it with a
# built-in name or your own list before adding traces.
plot.set_colorway("vivid") # "plotui", "muted", "vivid"
plot.set_colorway(["#e63c78", "cyan", (240, 161, 60)])
# Streaming: every add_* returns a trace handle. Append through it instead
# of rebuilding — O(new points), autoscale follows; numpy arrays are read
# in one bulk copy. set_visible toggles a series without losing its handle,
# palette slot, or node indices.
h = plot.add_line([], [], name="loss")
plot.extend(h, xs, ys) # 3D scatter/line: extend(h, xs, ys, zs)
plot.set_visible(h, False)
# Interaction (forward your framework's events to these):
plot.rotate(d_yaw, d_pitch)
plot.zoom_by(factor)
plot.pan(dx, dy)
plot.reset()
# Render (the frontend places the bytes):
escape = plot.render_kitty(cols, rows, cell_w, cell_h) # Kitty pixel image
pixels = plot.render_rgba(px_w, px_h) # raw RGBA8 bytes
Graphs take per-element styling, and the camera/projection state is fully scriptable — the hooks a host needs for label overlays, camera targeting, and rebuilding a plot without losing the view:
plot.add_graph3d(xs, ys, zs, edges=[(0, 1), (1, 2)],
node_colors=[...], # one (r, g, b) per node
node_sizes=[...], # per-node radius (else `size`)
edge_colors=[...], # per-edge (r, g, b) (else derived)
node_shapes=[...]) # per-node "disc" | "ring" | "square" |
# "triangle" | "diamond" | "diamond-open" | "dot"
plot.set_show_box(False) # hide the 3D orientation cube
plot.set_bounds((x0, y0, z0), (x1, y1, z1)) # pin the data frame (else the nodes'
# bounding box); None, None restores
plot.set_chrome(grid=(26, 32, 36), # recolour the non-data chrome to sit on
frame=(43, 50, 55), # your own background: bg (legend box),
ink=(103, 111, 118)) # frame, grid, ink, ink_bright
state = plot.camera_state() # (yaw, pitch, zoom, pan_x, pan_y)
plot.set_camera_state(*state) # restore (e.g. onto a new Plot)
plot.project_nodes(px_w, px_h) # [(x_px, y_px, depth)] per node —
# exact render/pick geometry
In Textual, use plotui.textual.PlotWidget(plot) and it handles the event
plumbing for you. Pass pickable=True to make 3D graph nodes and edges
interactive: hovering lights the element under the cursor up white, and
clicking posts an ElementPicked message with ("node", i) or ("edge", i)
(see examples/textual_graph.py, which opens a slide-in inspector from it).
The widget also supports text overlays — widget.set_overlay([(row, col, text, style), ...]) splices terminal-crisp text (labels, badges) over the
image in every render mode without re-rasterizing — and exposes a
widget.dragging property for hosts that defer work mid-gesture. To customize
interaction in a subclass, override the apply_rotate / apply_pan /
apply_zoom / apply_reset / on_click_at primitives that every input path
routes through — do not override the Textual on_* handlers (Textual
dispatches those to every class in the MRO, so both would run).
License
MIT, except for one embedded asset: chart text is set in
Martian Mono (Copyright 2020 The
Martian Mono Project Authors), used under the SIL Open Font License 1.1. The
glyph outlines are compiled into plotui-core as
crates/plotui-core/src/glyphs.rs; the license travels with them in
crates/plotui-core/fonts/MartianMono-OFL.txt. The font carries no Reserved
Font Name, and nothing about the OFL reaches your code — it covers the font
data, not the crate.
Metadata
Release files for plotui 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| plotui-0.5.1.tar.gz | 277.2 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| plotui-0.5.1-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| plotui-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| plotui-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| plotui-0.5.1-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| plotui-0.5.1-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 8.4 MB
Release files / plotui-0.5.1.tar.gz
| Download URL | plotui-0.5.1.tar.gz |
|---|---|
| Size | 277.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
60a5e7164adcd66b6eb779e6bb42c6b03e5b9c35bd2fa563e15fa8f7196bbfe5
|
|
BLAKE2b-256 checksum How to use checksums |
c893d469618be512cf06d0d8ed6767c3ca83a7cf13ee59f8e16afbc07a700530
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui-0.5.1-cp39-abi3-win_amd64.whl
| Download URL | plotui-0.5.1-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 1.4 MB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
ffbf7d90f974c3bd58be6980dd714a5384ea89e1ba21c55763a5dec8c20c6fc3
|
|
BLAKE2b-256 checksum How to use checksums |
8a57a806708ac67ed89799581eb01edecaeb28cb6f0e37e873aeaf52a2028424
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | plotui-0.5.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 1.8 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
63065e857a3a5839cc45721fe63142f9e6a2fd352304210867e58975003d3807
|
|
BLAKE2b-256 checksum How to use checksums |
e26d9e5a1d6b6b65bc47a04a0fa1c3f95269b813b642ddf6b9b93139e402f808
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | plotui-0.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 1.7 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
bed73adf4db2e3c7e2f00d10b35cd8120e19ee2121352fa7f9a5af097b761a34
|
|
BLAKE2b-256 checksum How to use checksums |
3c7827802b9e06e90e842dac7d4f7dc2a802806aa105f2830fe7c383797d9add
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui-0.5.1-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | plotui-0.5.1-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 1.6 MB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
27e70242887746c1dd087580a3d13f4a3267b6ee36e9908aca0310f2be006a92
|
|
BLAKE2b-256 checksum How to use checksums |
1b80e074ad7cc704f3b479eee39152ee7d5c35b8f4d008558a24c5ce06904f2e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui-0.5.1-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | plotui-0.5.1-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 1.7 MB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
3f578f79fc4084c945d0ed498b9fa3ddf9adebf4a81b41a17a0ffa8529ae6ffb
|
|
BLAKE2b-256 checksum How to use checksums |
f12fd7d450727b046b27ba8073c7162a9050cf0c49dc97578b38959527591f44
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|