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/bar charts with axes, ticks, and a legend; a 3D scatter/graph engine; a Kitty-image raw demo; and a Textual widget. See the roadmap below.
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.
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.
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)
cargo install plotui # build from source
cargo binstall plotui # prebuilt, via cargo-binstall
seq 1 100 | awk '{print $1, sin($1/10)}' | plotui line
plotui scatter -H -d, data.csv # header row + comma-delimited
plotui bar counts.tsv
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 palette slots in fixed order; `name=` puts a series in the legend.
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")
# 3D: any 3D trace switches the plot to the orbit camera.
plot = Plot()
plot.add_scatter3d(xs, ys, zs, color=(230, 60, 120), size=2.0)
# 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).
Roadmap
- Flicker-free Kitty placement via Unicode-placeholder virtual placement (fixed image id, atomic replace) — wire the pixel path into the Textual widget
- 2D traces: scatter, line, bar; axes, ticks, tick labels, legend
- Independent right-hand y-axes (
axis="y2"/"y3") with tinted tick labels - 2D step trace; axis titles; time-formatted x ticks
- 3D surface / mesh; axis cube with labels
- Interactive hover / pick for 3D graph nodes and edges (opt-in via
PlotWidget(..., pickable=True): hover lights the element up white, click postsElementPicked) - Hover / pick for 2D traces; spatial index for large graphs
- Streaming append: trace handles,
extend,set_visible, incremental bounds - numpy fast-path input (one bulk copy, no per-element conversion)
- Rolling window (
max_points) for endless streams - Graceful render-path auto-detection (placeholder / direct Kitty, with a
supported-terminals notice elsewhere and a
PLOTUI_RENDERoverride) - Sixel + iTerm2 OSC 1337 encoders for terminals without Kitty graphics
- Prebuilt wheels (maturin + cibuildwheel)
- Ratatui frontend (native):
plotui-ratatui— StatefulWidget + app-owned PlotState, full parity with the Textual widget (cargo run -p plotui-ratatui --example demo) - Bubble Tea frontend (cgo):
go/bindings over theplotui-ffiC ABI + theteaplotcomponent for Bubble Tea v2 (seego/README.md) - Prebuilt static libs for the Go bindings (today: local source build)
License
MIT
Release files for plotui-cli 0.4.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_cli-0.4.1.tar.gz | 122.3 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| plotui_cli-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | Python 3 | none | Linux glibc 2.17+ x86-64 | Details |
| plotui_cli-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | Python 3 | none | Linux glibc 2.17+ ARM64 | Details |
| plotui_cli-0.4.1-py3-none-macosx_11_0_arm64.whl | Python 3 | none | macOS 11.0+ ARM64 | Details |
| plotui_cli-0.4.1-py3-none-macosx_10_12_x86_64.whl | Python 3 | none | macOS 10.12+ x86-64 | Details |
Total release size: 2.9 MB
Release files / plotui_cli-0.4.1.tar.gz
| Download URL | plotui_cli-0.4.1.tar.gz |
|---|---|
| Size | 122.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
863f3010d27ddaaf7edc08f0f974dcd582ed415f1e13029a4e3690d60473831f
|
|
BLAKE2b-256 checksum How to use checksums |
d640cf68cb238d82dbe0b0619dfeeeaa233adea248e3838a78aedd493573512e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui_cli-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | plotui_cli-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 734.4 kB |
| Tags | Linux glibc 2.17+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
3c40524d70c8ecfd4303a99b3df7a4682ca4ce2e70f09f19504d89a7529e62b3
|
|
BLAKE2b-256 checksum How to use checksums |
5e2387c350665914c6d63b7467788541a1363613e95742cbdb77ab5bbe057ce2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui_cli-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | plotui_cli-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 715.5 kB |
| Tags | Linux glibc 2.17+ ARM64 Python 3 |
|
SHA-256 checksum How to use checksums |
729e21ba6ce3f805e8784323d0b3bb146b5a0756d59835e5945435e17b292fea
|
|
BLAKE2b-256 checksum How to use checksums |
f6b4546943fa26d7c186c30476c859cd6f6fda1c3df6f86dbb8da8cb2f444b15
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui_cli-0.4.1-py3-none-macosx_11_0_arm64.whl
| Download URL | plotui_cli-0.4.1-py3-none-macosx_11_0_arm64.whl |
|---|---|
| Size | 676.9 kB |
| Tags | Python 3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
b09c3b5c15f8d6f94897cde381157da8c32ab1322aa26e7c43bdda77b7a5ed1a
|
|
BLAKE2b-256 checksum How to use checksums |
3c8c340bd48b0765f3262d7124cd1d53e515a0f8c6331d9657de6483cbd39e1b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / plotui_cli-0.4.1-py3-none-macosx_10_12_x86_64.whl
| Download URL | plotui_cli-0.4.1-py3-none-macosx_10_12_x86_64.whl |
|---|---|
| Size | 690.4 kB |
| Tags | Python 3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
e5375fba73a9e84dfc1533ac662840c8ac6f4fe25c802bcc0764594fec3a9354
|
|
BLAKE2b-256 checksum How to use checksums |
aea87ffb8391cbaeacc7e3151024c3e31fdc3d90080880aa0a4619e8d1c5b0cb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|