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

Kittyplaceholder
Ghosttyplaceholder
iTerm2 ≥ 3.5direct
WezTermdirect
Konsoledirect
Warpdirect · partial
Riodirect · partial
VS Codedirect · partial
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 axesaxis="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 ElementPicked message.
  • 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)

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

status: early scaffold

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.

2D scatter · line · bar
3D surface / mesh
secondary y-axes
2D hover & pick
3D graph picking
streaming extend · numpy input
render auto-detect
rolling window (max_points)
tmux passthrough
prebuilt wheels
trace visibility toggles
Ratatui / Bubble Tea
# 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.