Beautiful and
interactive plots,
right inside
your terminal
plotui draws interactive, Plotly-style 2D & 3D charts inside the terminal — rasterized by a Rust engine, delivered over the Kitty graphics protocol, embeddable in TUI frameworks such as Textual, Ratatui, or Bubble Tea. Select, rotate, pan, zoom, hover.
compatibility
Your framework, your terminal — supported.
A first-class widget for your TUI framework, real pixels in your terminal. If your pair is below, plotui just works — and feels the same everywhere: drag rotates, shift-drag pans, scroll zooms, clicks pick.
TUI frameworks
Textual
python
Drop PlotWidget into any Textual app — rotate, zoom, hover and click picking, live streaming updates. One import and you're plotting.
from plotui.textual import PlotWidget
Ratatui
rust
A native StatefulWidget — draw it like any other widget, hand it your events, and get flicker-free interactive plots in your Rust TUI.
use plotui_ratatui::PlotWidget;
Bubble Tea
go
The teaplot component for Bubble Tea v2 — Elm-style
Update/View, with pick and
hover messages ready for your update loop.
github.com/sebaheg/plotui/go/teaplot
Same engine behind all three — a plot looks and feels identical whichever framework hosts it.
Terminals
- placeholder
- the richest mode — plots are woven into the character grid, so they scroll, split, and repaint exactly like text
- direct
- pixels drawn straight at the plot's position, just as sharp — plotui manages repaints itself
- ~ partial
- these terminals' pixel-graphics support is new and still maturing — plots work, with occasional rough edges
plotui detects your terminal and picks the best render path on its own — no configuration, and it works inside tmux too (plots pass straight through to the hosting terminal). Terminals without pixel graphics get a clear notice instead: plotui never degrades to a fake plot.
python · rust · go
An API you already know how to use.
Add traces, name them, and axes, ticks, and a legend appear on their own. The first 3D trace switches the plot to the orbit camera.
- Secondary axes —
axis="y2"/"y3"binds a series to an independent right-hand axis, tick labels tinted to the series color. - Pickable 3D graphs — hover lights nodes and edges up white; a click posts an
ElementPickedmessage. - Scriptable camera — save and restore view state, project nodes to exact pixel coordinates for overlays and hit-testing.
- Text overlays — splice terminal-crisp labels over the image without re-rasterizing.
from plotui import Plot
plot = Plot()
# the scene next to the header — one trace per cluster
plot.add_scatter3d(x_a, y_a, z_a, color=(230, 60, 120))
plot.add_scatter3d(x_b, y_b, z_b, color=(69, 200, 209))
plot.add_scatter3d(x_c, y_c, z_c, color=(240, 161, 60))
# forward your framework's events…
plot.rotate(d_yaw, d_pitch)
plot.zoom_by(factor)
# …and place the bytes
escape = plot.render_kitty(cols, rows, cell_w, cell_h)
use plotui_core::Plot;
let mut plot = Plot::new();
// the scene next to the header — one trace per cluster
plot.add_scatter3d(cluster_a, [230, 60, 120], 2.0);
plot.add_scatter3d(cluster_b, [69, 200, 209], 2.0);
plot.add_scatter3d(cluster_c, [240, 161, 60], 2.0);
// forward your framework's events…
plot.rotate(d_yaw, d_pitch);
plot.zoom_by(factor);
// …and place the bytes
let fb = plot.render(cols * cell_w, rows * cell_h);
let escape = plotui_protocol::kitty(&fb, cols, rows);
import plotui "github.com/sebaheg/plotui/go"
plot := plotui.New()
// the scene next to the header — one trace per cluster
col := plotui.WithColor
plot.AddScatter3D(xA, yA, zA, col(plotui.RGB{230, 60, 120}))
plot.AddScatter3D(xB, yB, zB, col(plotui.RGB{69, 200, 209}))
plot.AddScatter3D(xC, yC, zC, col(plotui.RGB{240, 161, 60}))
// forward your framework's events…
plot.Rotate(dYaw, dPitch)
plot.ZoomBy(factor)
// …and place the bytes
escape, _ := plot.RenderKitty(cols, rows, cellW, cellH, opts)
more live plots
Surfaces, and every 2D chart, from the same engine.
Like the header, these are in-browser re-creations of what the Rust rasterizer draws in your terminal: a Gouraud-shaded grid surface on the viridis ramp, and the 2D chart types with their automatic axes, ticks, and legend.
streaming
Stream data in. Never rebuild.
Every add_* call returns a
stable trace handle. Append through it and the cost is proportional to the new
points — not the history — while axes autoscale to the grown data on the next frame.
numpy arrays cross into the Rust core in one bulk copy.
- widget.extend(h, xs, ys) — append + repaint; repeated extends between frames coalesce into one raster.
- widget.set_visible(h, False) — hide a series without losing its handle, palette slot, or picking indices. Click the legend on the right to try it.
- Incremental bounds — per-trace cached extents, so a long-running dashboard never rescans its history to draw a frame.
# handles come back from add_*
h = plot.add_line([], [], name="forecast")
o = plot.add_scatter([], [], name="observed")
g = plot.add_line([], [], name="load", axis="y2")
def feed(): # 20 times a second
widget.extend(h, [t], [forecast(t)])
widget.extend(o, [t], [measured(t)])
widget.extend(g, [t], [load(t)])
widget.set_visible(o, False) # legend toggle
every frame: the delta crosses FFI, per-trace bounds update in O(new points), the Rust core re-rasterizes, one Kitty escape replaces the image.
get started
Build from source today. Wheels are coming.
You need Rust and Python 3.9+. maturin compiles the native module into your virtualenv; then run the demos in any supported terminal.
# clone, then:
❯ python -m venv .venv && source .venv/bin/activate
❯ pip install maturin textual
❯ maturin develop --release
# in Kitty / Ghostty / iTerm2 / WezTerm:
❯ python examples/raw_demo.py
❯ python examples/textual_graph.py
how it works
The Rust core owns pixels — not the terminal.
One rule shapes everything: the engine has no event loop and no input handling. Your TUI framework owns the loop, forwards input to the camera, and asks for a frame. Because the core and the protocol layer are pure and I/O-free, the same engine can back every frontend — and be unit-tested by hashing pixel buffers.
f64 data─▶camera + rasterizer─▶RGBA buffer─▶kitty escape bytes─▶your terminal's cell grid
plotui-core
the engine
Pure Rust: data model, 3D orbit camera, rasterizer straight to RGBA. Scatter, line, bar, secondary y-axes, 3D graphs with per-node styling.
plotui-protocol
the wire
RGBA in, terminal bytes out. Kitty graphics escapes — chunked, placement-aware, and wrapped for tmux passthrough. Pure functions, no I/O.
PlotWidget
the plumbing
Drop it into a Textual app. It picks the render path per terminal, routes mouse events to the camera, and repaints without flicker. Hover + click picking for 3D graphs built in.