opsis
A Vega-style plotting framework for Rust. Describe a chart in TOML, then render it as a native desktop window (egui) or a terminal UI (ratatui).
Python users can drive the same renderer via PyO3 bindings — no Rust knowledge required.
Table of contents
- Quick start (Rust)
- Quick start (Python)
- The spec format
- Chart types
- Loading data
- Embedding in an existing app
- Adding a new chart type
- Feature flags
- Building & testing
- Project layout
1. Quick start (Rust)
Add opsis to your Cargo.toml:
[]
= { = "path/to/opsis" } # or use git/crates.io once published
Write a TOML spec (or start from examples/configs/bar.toml):
[]
= "bar"
= "Quarterly revenue"
[]
= [
{ = "Q1", = 120 },
{ = "Q2", = 145 },
{ = "Q3", = 132 },
{ = "Q4", = 178 },
]
[]
= "quarter"
= "categorical"
[]
= "revenue"
= "quantitative"
[]
= "#4C78A8"
Then in Rust:
You can also parse and use the spec programmatically:
use ;
let spec = from_toml_path?;
spec.validate?; // returns Err with a helpful message on bad config
let data: Dataset = spec.load_data?; // loads inline values or reads the file
println!;
Try the included demo binaries:
# native window
# terminal
2. Quick start (Python)
Install
Build and install the Python extension with maturin:
This compiles the Rust code and installs opsis as a regular Python package
in the current environment (virtual env or conda env).
Use
# From a TOML file ─────────────────────────────────────────────────────────
# egui window (blocks)
# terminal TUI (q to quit)
# From a TOML string ────────────────────────────────────────────────────────
=
# From a Python dict (no TOML needed) ───────────────────────────────────────
=
# egui window
# terminal
# Validate without rendering ─────────────────────────────────────────────────
=
# prints the re-serialised TOML on success
3. The spec format
A spec is a single TOML file with four top-level sections.
3.1 [chart]
[]
= "bar" # required — see §4 for all values
= "My chart" # optional window/widget title
= 900 # egui only — window width in logical pixels
= 600 # egui only — window height in logical pixels
3.2 [data]
Two forms — pick one.
Inline values (great for small datasets or demos):
[]
= [
{ = "Alice", = 92 },
{ = "Bob", = 78 },
]
Records are free-form TOML objects. Any key is a valid field name.
Numbers are read as f64; everything else as a string.
External file (CSV or JSON):
[]
= "results.csv" # path relative to the working directory
# format = "csv" # optional; inferred from extension (.csv/.tsv → csv, .json → json)
A CSV file must have a header row. Numeric cells are automatically promoted
to numbers; empty cells become null.
A JSON file must be a JSON array of objects:
3.3 [encoding]
Encodings map dataset fields to visual channels.
[]
= "quarter" # column name in your dataset
= "categorical" # quantitative | categorical | temporal | ordinal
= "Quarter" # axis/legend label (defaults to field name)
= "sum" # sum | mean | count | min | max
# when multiple rows share the same x value,
# the y values are collapsed using this function
[]
= "revenue"
= "quantitative"
# aggregate = "mean" # optional
[] # required for: pie, histogram
= "amount"
[] # pie slice labels (falls back to encoding.x)
= "product"
[] # heatmap intensity (alias for value)
= "density"
Which channels does each chart type use?
| Chart | Required | Optional |
|---|---|---|
bar |
x, y |
— |
line |
x, y |
— |
area |
x, y |
— |
scatter |
x, y |
— |
histogram |
value (or x) |
— |
pie |
value |
category (or x) |
heatmap |
x, y, value (or color) |
— |
box_plot |
x (categorical), y (numeric) |
— |
3.4 [style]
All fields are optional.
[]
= "#4C78A8" # primary hex colour (short #RGB or full #RRGGBB)
= ["#4C78A8", "#F58518", "#54A24B"]
# per-slice/per-bar colour cycle
= "#ffffff" # reserved (not yet rendered)
= 10 # histogram: number of bins (default: Sturges' rule)
= true # show grid lines (default: true)
= true # show legend (default: true)
4. Chart types
4.1 bar
Groups records by the x field (categorical) and aggregates y (default: sum).
[]
= "bar"
= "Sales by region"
[]
= [
{ = "North", = 420 },
{ = "South", = 380 },
{ = "East", = 510 },
{ = "West", = 460 },
]
[]
= "region"
= "categorical"
[]
= "sales"
= "sum"
If you have repeated categories (e.g. daily data grouped by month), set
aggregate on [encoding.y]:
[]
= "revenue"
= "mean" # show average per category
4.2 line
Plots (x, y) pairs connected by a line. x is typically numeric.
[]
= "line"
= "Temperature over time"
[]
= "temperature.csv"
[]
= "day"
[]
= "temp_c"
= "°C"
4.3 area
Same as line but fills the area below the curve.
[]
= "area"
= "Cumulative signups"
[]
= "week"
[]
= "signups"
4.4 scatter
Plots each record as an independent point.
[]
= "scatter"
= "Height vs weight"
[]
= "height_cm"
= "Height (cm)"
[]
= "weight_kg"
= "Weight (kg)"
4.5 histogram
Bins a single numeric field into ranges and counts how many records fall in
each. Use [encoding.value] (or [encoding.x]) to pick the field; set
style.bins to control the number of bins (default uses Sturges' rule).
[]
= "histogram"
= "Response time distribution"
[]
= "timings.csv"
[]
= "response_ms"
[]
= 20
= "#72B7B2"
4.6 pie
Draws one slice per category, sized by value. Use [encoding.category] for
labels and [encoding.value] for the numeric measure.
[]
= "pie"
= "OS market share"
[]
= [
{ = "Windows", = 72 },
{ = "macOS", = 15 },
{ = "Linux", = 4 },
{ = "Other", = 9 },
]
[]
= "os"
[]
= "share"
[]
= ["#4C78A8", "#F58518", "#54A24B", "#E45756"]
= true
ratatui note: pie is rendered as a horizontal bar chart of percentages because terminals can't paint circular arcs. egui draws real arc-based slices.
4.7 heatmap
A grid where colour intensity encodes the value. x and y are categorical;
value (or color) holds the numeric intensity.
[]
= "heatmap"
= "Support tickets by day and hour"
[]
= "tickets.csv" # columns: weekday, hour, count
[]
= "hour"
[]
= "weekday"
[]
= "count"
[]
= "#B279A2" # high-intensity end of the colour ramp
4.8 box_plot
Calculates min / Q1 / median / Q3 / max per category and draws a box-and-whisker plot.
[]
= "box_plot"
= "Latency by region"
[]
= "latency.csv" # columns: region, latency_ms
[]
= "region"
= "categorical"
[]
= "latency_ms"
= "quantitative"
5. Loading data
opsis exposes the Dataset type for use in Rust code:
use Dataset;
// From a CSV file
let data = from_csv_path?;
// From a JSON string
let data = from_json_str?;
// Extract columns
let values: = data.column_f64?;
let labels: = data.column_str?;
println!; // all column names
println!;
You can build records manually:
use ;
let mut record = new;
record.insert;
record.insert;
let data = new;
6. Embedding in an existing app
The show* functions block the calling thread. If you want to embed a chart
inside your own egui or ratatui app, call the backend functions directly:
egui
use ;
use egui_backend;
// Inside your eframe::App::update() method:
ratatui
use ratatui_backend;
// Inside your ratatui draw closure:
terminal.draw?;
7. Adding a new chart type
Three small edits are needed:
1. Add the variant in opsis/src/config.rs:
2. Add aggregation logic in opsis/src/render/mod.rs (optional — reuse
extract_xy or aggregate_categorical if the shape fits, otherwise write a
new builder function following the existing patterns).
3. Add a match arm in both backends:
// opsis/src/render/egui_backend.rs — inside draw()
Candlestick => draw_candlestick,
// opsis/src/render/ratatui_backend.rs — inside render()
Candlestick => render_candlestick,
8. Feature flags
[]
# Default: both backends enabled.
= { = "..." }
# Only egui (no terminal dependency).
= { = "...", = false, = ["egui-backend"] }
# Only ratatui (no GUI dependency — good for servers / CI).
= { = "...", = false, = ["ratatui-backend"] }
# Headless: just parsing + data loading. No rendering at all.
= { = "...", = false }
| Feature | Adds | Use when |
|---|---|---|
egui-backend |
egui, eframe, egui_plot |
building a desktop app |
ratatui-backend |
ratatui, crossterm |
building a CLI or server tool |
| (neither) | — | only need TOML parsing and data aggregation |
9. Building & testing
# Check everything
# Build
# Run unit tests
# Run the egui demo (opens a window)
# Run the ratatui demo (press q to quit)
# Python bindings
10. Project layout
.
├── Cargo.toml workspace root
├── README.md
├── opsis/ core library
│ ├── Cargo.toml
│ ├── examples/
│ │ ├── egui_demo.rs cargo run --example egui_demo
│ │ └── ratatui_demo.rs cargo run --example ratatui_demo
│ └── src/
│ ├── lib.rs public API (show / show_terminal / *_path / *_str)
│ ├── config.rs spec types (ChartSpec, Encoding, Style, …)
│ ├── data.rs Dataset, Value, CSV/JSON loaders
│ ├── error.rs OpsisError
│ └── render/
│ ├── mod.rs shared aggregation helpers (bar_data, pie_data, …)
│ ├── egui_backend.rs egui_plot + custom pie arc + custom heatmap grid
│ └── ratatui_backend.rs ratatui BarChart/Chart + ASCII pie/heatmap/box
├── opsis-py/ Python extension (PyO3 / maturin)
│ ├── Cargo.toml
│ ├── pyproject.toml maturin build config
│ └── src/lib.rs show / show_dict / show_terminal* / validate
└── examples/
├── configs/ ready-to-use TOML specs
│ ├── bar.toml
│ ├── line.toml
│ ├── area.toml
│ ├── scatter.toml
│ ├── histogram.toml
│ ├── pie.toml
│ ├── heatmap.toml
│ └── boxplot.toml
└── python/
└── demo.py Python demo (show / show_terminal / show_dict)
Why "opsis"?
Greek ὄψις — "appearance, sight". Short, no name clashes, easy to type.
License
Dual-licensed under MIT or Apache-2.0, at your option.