Building a Native GPUI Chart in Rust: A GEX Gamma Heatmap

A production Rust charting library that renders candlesticks, an orderbook heatmap, and a GEX gamma-exposure heatmap natively — the same engine embeds in a GPUI desktop app and on the web.

Most charting libraries answer one question: "how do I put a chart on a web page?" This post answers a different one: "how do I embed a real trading chart — candlesticks, an orderbook heatmap, and a GEX gamma-exposure heatmap — inside a native GPUI desktop app in Rust?" And it shows that the same engine also runs on the web, so you build once and ship to both.

We build kline-orderbook-chart. It started as a web chart engine and grew a native path, because a serious trading terminal eventually wants to leave the browser: lower latency, no tab throttling, direct GPU access, and a single binary you control. This is how it works.

Native trading terminal: an options GEX gamma-exposure heatmap on the left, a BTCUSDT kline chart with orderbook heatmap and RSI on the right, both rendered by one Rust engineA native terminal: the GEX gamma heatmap (left) and the kline + orderbook-heatmap chart (right), one engine driving both panes.

Why a native chart at all?

The web is a great distribution channel and a hostile runtime for a chart. Background tabs get throttled to 1 fps. Garbage-collection pauses land mid-pan. Canvas is a single-threaded raster target with no direct GPU control. For a chart a trader stares at for eight hours, those tradeoffs add up.

A native build removes them: predictable frame pacing, real threads, GPU-backed painting through GPUI / wgpu / skia, and no browser sandbox between you and the metal. The catch has always been that going native meant rewriting your chart. It does not have to.

One engine, one command buffer

The core idea that makes web-and-native possible with no rewrite: the engine never touches a canvas or a GPU directly. Each frame it computes a command buffer — a compact, renderer-agnostic list of typed draw commands:

Code
FillRect { x, y, w, h, color }
Line { x0, y0, x1, y1, color, width }
Text { x, y, text, size, color }
HeatmapWorld { … }        // the depth / GEX raster in world coordinates
Circle { … } · Polyline { … } · GradientRect { … } · ClipRect { … } · …

On the web, a thin layer reads that buffer and calls ctx.fillRect, ctx.stroke, ctx.fillText. Natively, you read the same buffer and call your painter. The engine does not know or care which one it is.

One engine, two front-ends: a browser window and a native desktop window drawing the identical candlestick-plus-heatmap chart from a single shared engine coreSame command stream, two dispatchers — a browser canvas and a native GPUI window.

The native Rust API

No JavaScript, no WASM, no bridge. You use the crate directly:

rust
use chart_engine::ChartEngine;
use chart_engine::render::Paint;

// 1. Create the engine at a logical (CSS-pixel) size.
let mut engine = ChartEngine::new(960.0, 600.0);

// 2. Activate your licence (native tokens skip the web domain check).
engine.set_license(include_str!("../license.mrd").trim());

// 3. Feed columnar OHLCV (timestamps in ms).
engine.set_klines(&ts, &open, &high, &low, &close, &volume);

// 4. Render one frame, then walk its command buffer.
for cmd in engine.commands() {
    match cmd {
        Paint::FillRect { x, y, w, h, color } => painter.fill_rect(x, y, w, h, color),
        Paint::Line { x0, y0, x1, y1, color, width } => painter.line(x0, y0, x1, y1, color, width),
        Paint::Text { x, y, text, size, color, .. } => painter.text(x, y, text, size, color),
        Paint::HeatmapWorld { .. } => painter.heatmap(cmd),   // depth / GEX raster
        _ => {}
    }
}

engine.render_frame() renders and returns the raw bytes; engine.commands() gives you a streaming iterator of typed Paint values over the last frame. That match block is your renderer adapter — a couple hundred lines mapping ~30 command variants to your backend.

Wiring it into GPUI

GPUI is the Rust UI framework behind the Zed editor: retained-mode elements, a GPU-backed painter, and a tight frame loop. The chart becomes a single custom element whose paint pass drains the command buffer:

rust
impl Element for ChartElement {
    fn paint(&mut self, bounds: Bounds<Pixels>, _: &mut (), cx: &mut WindowContext) {
        let mut engine = self.engine.borrow_mut();
        engine.set_size(bounds.size.width.into(), bounds.size.height.into());
        engine.render();

        for cmd in engine.commands() {
            match cmd {
                Paint::FillRect { x, y, w, h, color } => {
                    cx.paint_quad(fill(rect(x, y, w, h, bounds), rgba(color)));
                }
                Paint::Line { x0, y0, x1, y1, color, width } => {
                    cx.paint_path(line_path(x0, y0, x1, y1, bounds), rgba(color), width);
                }
                Paint::Text { x, y, text, size, color, .. } => {
                    cx.paint_text(point(x, y, bounds), text, size, rgba(color));
                }
                Paint::HeatmapWorld { .. } => paint_heatmap(cx, &cmd, bounds),
                _ => {}
            }
        }
    }
}

Feed pan/zoom/crosshair from GPUI's input events into the engine's viewport setters (set_viewport, set_crosshair), request a repaint, and you have a native, GPU-painted trading chart. A reference adapter ships in the repo — start there instead of writing the match from scratch.

The GEX gamma-exposure heatmap

GEX (gamma exposure) turns the options chain into a map of where dealers are forced to hedge. From the open interest at each strike you estimate dealers' net gamma; positive-gamma zones tend to pin price (dealers sell rallies, buy dips), negative-gamma zones tend to accelerate it. Rendered as a heatmap — price on Y, time on X, cell color by dealer gamma — you can see those magnet and accelerant zones at a glance, and line them up against the spot candles in the next pane.

Mechanically it is the same HeatmapWorld raster path as the orderbook depth heatmap; only the input matrix differs (dealer gamma per strike instead of resting size per price). The engine renders both the same way, natively and on the web, so the GEX pane in the screenshot above is the identical code path as the depth heatmap in the chart pane. New to GEX? Start with What Is Gamma Exposure (GEX)? A Trader's Guide and GEX Heatmap Explained.

What you get in the box

The native crate is not a candle-only stub — it is the full engine:

  • Candlesticks with O(1) amortised appends for live bars.
  • Orderbook depth heatmap — resting liquidity behind the candles.
  • GEX gamma heatmap — dealer gamma by strike over time.
  • Footprint cells — bid×ask executed volume, POC, delta, stacked imbalances.
  • Liquidation heatmap — force-close clusters as an overlay.
  • 18+ indicators — VRVP, CVD, TPO, RSI, MACD, Open Interest, Funding, and more.
  • Custom indicators — a sandboxed scripting language, no fork required.

Every one of those is a command-buffer producer, so every one renders identically on the web and in your GPUI window.

Web and native, from one build

To be explicit, because this is the part that is genuinely rare: the same source produces both targets.

  • Web — install kline-orderbook-chart from npm, hand it a <canvas>, feed OHLCV, done. See Kline Chart With Orderbook Heatmap: A Library Guide.
  • Native — add the licensed native crate (a precompiled engine plus an open decoder/adapter — you licence the binary, the same model as the web build; the engine source is not sold), implement the ~30-variant Paint adapter for GPUI / wgpu / skia (a reference GPUI adapter is provided), done.

Same candles, same heatmaps, same numbers, same drawing tools. You do not maintain two charts; you maintain one engine and two thin dispatchers.

Where to go next


Built with kline-orderbook-chart — a Rust chart engine for web and native. Free for development, commercial licences for production. This article is engineering documentation, not financial advice. Trading involves risk.

Join the community