malevich

Look up

Interaction

Mapping, Viewport, and the widget. Zoom, pan, and crosshairs, without owning the terminal.

How a chart becomes interactive. The core exposes the physics. The ratatui widget runs a controller on top of it. Some patterns need no library support at all. The boundary is strict: malevich never reads input, and never owns an event loop. Everything here is what that boundary enables.

The three layers#

layerownslives in
physicscell ↔ data mapping, window arithmetic, snapping lookupscore: Mapping, Viewport, stat::nearest
controllergesture policy, hover/drag state, overlay drawingthe widget: PlotState (feature ratatui; npm malevich/ink)
hostevent loop, mouse capture, key bindings, app stateyour code

The controller is to interaction what presets are to the grammar: a proven composition of the public physics. Different policy wanted? Skip on_mouse and drive Viewport and Mapping yourself — the same escape hatch.

The Ink adapter (malevich/ink on npm) is the same split under a different host. PlotWidget paints a Raster as Ink Text cells. PlotState is the same controller: same Mouse vocabulary, same overlays into the raster. The widget never reads stdin. usePlotInteraction is an optional composition. It enables DECSET mouse tracking on Ink's stdout and parses SGR. Skip it and feed onMouse yourself, the way a ratatui host maps crossterm events. Linked panes are linkX(active, passive): share the x window by assignment, mirror the cursor as a data x. Live tours in the repo: cd js && npm run example:zoom and npm run example:linked.

The physics#

100,000 points, the whole window 5.0 ┤ │ 2.5 ┤ │ │ 0.0 ┤ │ -2.5 ┤ │ └┬────────────┬────────────┬────────────┬────────────┬────────────┬ 0 20k 40k 60k 80k 100k the same plot, zoomed 5.0 ┤ │ 2.5 ┤ │ │ 0.0 ┤ │ -2.5 ┤ │ └┬──────────┬──────────┬──────────┬─────────┬──────────┬──────────┬ 41.00k 41.50k 42.00k 42.50k 43.00k 43.50k 44.00k
Plate 1. A zoom is a domain window. The whole series is on the left. On the right, a Viewport over three thousand of its hundred thousand points, where M4 re-aggregates to the new columns, and the ripple the wide view could only hint at is drawn in full.

Plot::mapping(&frame) runs the same resolve → layout pass a render runs, and returns where everything landed, as a plain value:

  • data_at(column, row) / cell_at(x, y) — the scale contract's invert, reachable at plot level. Band axes answer in band-index space, time axes in unix seconds. column_at(x) is the x-only half of cell_at — the linked-crosshair primitive.
  • x_span_at(column) — the data interval one column covers; a cell-level cursor's honest resolution.
  • format_x / format_y — a value written the way the axis writes its own labels: exact decimals at cell resolution, calendar instants, category names. Never 0.30000000000000004, never more precision than a cell has.
  • x_domain / y_domain — the resolved windows. viewport() returns those windows as a Viewport, the seed for zoom and pan.

Viewport is the view as a value: zoom_x(factor, anchor) (decade space on log axes, so equal gestures cover equal factors), pan_x(fraction), clamp_x(extent), tail(latest, width), reset(). Apply it with Plot::viewport(view) — pure sugar over x_domain/y_domain. That is the load-bearing trick: a zoom is a scale option, not a render mode, so M4 re-aggregates to the visible window on the next render. Drilling into ten million points is rendering (cargo run --release --example zoom --features ratatui).

The controller#

let mut chart = PlotState::default();                    // host state, once
frame.render_stateful_widget(plot.widget(), area, &mut chart);
// in the event loop:
if let Event::Mouse(raw) = event && let Some(input) = mouse(raw) {
    chart.on_mouse(input);                               // returns "changed?"
}

The stateful render caches the frame's Mapping, so hit-testing answers against exactly what is on screen. It applies the state's Viewport and draws the interaction chrome. mouse is a six-line match from your backend's event type to the neutral Mouse vocabulary, printed in full in the Mouse rustdoc. Mouse capture is yours to turn on (EnableMouseCapture in crossterm).

The gesture grammar, fixed on purpose:

inputeffect
hovercrosshair; the readout snaps to the data (below)
wheelx zoom anchored at the data under the cursor
left dragpan, every continuous axis
right dragrubber-band selection; zooms to it on release
reset_view() / zoom_in() / zoom_out() / pan_left() / pan_right()for the host's key bindings

Coordinates outside the plot rectangle are ignored. Band axes have no continuous window and stay untouched.

Snapping. For every point-backed Line and Points layer, the readout lists the datum nearest the cursor's x inside the visible window — label: value, axis-formatted, its cell highlighted. A gap reads as —, never an interpolation; off-window data never snaps. snap(false) returns plain cursor coordinates; crosshair(false) and readout(false) suppress the other overlays. Overlays draw into the buffer only — the plot value renders byte-identically with or without them.

Patterns that need no API#

Linked panes. Two stacked charts share one x view by mirroring the window after each event — route the event to the pane it landed on, then:

let window = active.viewport().x();
let view = passive.viewport();
passive.set_viewport(match window {
    Some((lo, hi)) => view.with_x(lo, hi),
    None => view.reset_x(),
});

Zoom and pan in either pane and both move; each pane keeps its own y. fred's series view does exactly this — a year-over-year context strip under the main chart, linked on x. The absence of a "linking" feature is the design: a view is a value, so sharing it is assignment.

The crosshair links the same way — a hover is a data x, so sharing it is a call. After routing a Moved to the pane it landed on, mirror the active pane's cursor into the passive one:

let x = active.cursor_data().map_or(f64::NAN, |(x, _)| x);
passive.hover_x(x);   // NaN — or an off-window x — clears the mirror

The passive pane draws a vertical-only crosshair at its own column for that x (Mapping::column_at is the physics underneath, so differing gutters cannot misalign it), snaps its own series, and reads out the same instant. There is no horizontal line and no y readout — a mirrored hover has an x but no honest row to claim. fred mirrors both ways on its series view, and across all six panes of its overview: one calendar, one crosshair.

Selection → statistics. A rubber-band zoom is a selection: after it, viewport().x() is the chosen window, and the visible data summarizes with the ordinary stat vocabulary —

if let Some((lo, hi)) = chart.viewport().x() {
    let mut visible = Moments::new();
    for (&x, &y) in xs.iter().zip(ys) {
        if (lo..=hi).contains(&x) { visible.add(y); }
    }
    // count / mean / sd / min / max, formatted by the axis:
    let label = chart.mapping().map(|m| m.format_x(lo));
}

fred's footer shows this: zoom into any span and the stats line describes what is on screen, dated by format_x.

Modifier gestures. The default grammar is modifier-free on purpose. The Mouse vocabulary carries no modifier keys, because terminals report them unevenly: xterm reserves shift for selection, and not every backend forwards alt. A fixed grammar must not half-work per terminal. A host that wants shift-wheel to zoom y, or any modifier binding, reads the modifier from its own backend event — it had the raw event in hand to build the Mouse value at all — and drives the physics directly:

if shift_held && let Some((_, anchor_y)) = chart.data_at(column, row) {
    let mut view = chart.mapping().map(|m| m.viewport()).unwrap_or_default();
    if let Some((lo, hi)) = chart.viewport().x() { view = view.with_x(lo, hi); }
    if let Some((lo, hi)) = chart.viewport().y() { view = view.with_y(lo, hi); }
    chart.set_viewport(view.zoom_y(0.8, anchor_y));
}

The seeding rule is the one the built-in gestures use: the mapping's rendered windows, overlaid with any window the view has already fixed. Modifier gestures then compound correctly with wheel zooms and drags between renders.

Follow the stream. A live chart tails its ring buffer in one line — view.tail(latest_x, width) — and a user's zoom naturally suspends the follow until reset(). sysmon pins its dashboard axes this way: the full two-minute window holds still from the first sample instead of rescaling while the rings fill.

Real pixels#

With the pixel feature, an interactive widget can draw its panel as a real image — sixel, kitty, iTerm2 — while everything above keeps working:

// Before ratatui::init(): the probe reads replies raw mode would swallow.
let graphics = malevich::pixel::Capabilities::detect_for(&std::io::stdout()).best();
// Render: same stateful widget, plus the protocol.
frame.render_stateful_widget(plot.widget().graphics(g), area, &mut chart);
// After terminal.draw: emit the pending image blocks in one synchronized swap.
g.present(&mut std::io::stdout(), &mut [&mut chart])?;

The widget reserves its rectangle in the buffer: spaces, skip-marked, so ratatui's diff never writes under the image. Whenever the rectangle changes, there is one fresh-ground frame. The encoded block is stored in the PlotState. Repaints never flicker. Image data travels transmit-only under a stable per-panel id. The presenter creates a fresh placement under an alternating placement id before retiring the one on screen. There is no deleted-but-not-yet-drawn gap for the eye to catch, and no reliance on any terminal's replacement semantics. A panel whose content already matches the screen is not transmitted. Graphics::present writes exactly what it is told to the handle it is given (the stream::Live precedent). Emit on state changes, not on a timer, and the previous transmission stays on screen through quiet frames. Graphics::retire deletes the panels' own images — by id, never touching other applications' — when a view switch leaves the charts, and resets their states so the return paints fresh ground.

Hit-testing, zoom, pan, and snapping are unchanged. The mapping answers in cells, whatever fills them. The interaction chrome upgrades: crosshair rules, snap markers, and the readout render into the image as annotation marks — anti-aliased, never palette-consuming. Automatic axes stay pinned to the last frame, so hovering cannot jitter them. A viewport-fixed axis is never pinned: the window a gesture set always renders. fred does all of this when its terminal speaks a protocol (--cells opts out).

Two rates keep it smooth. The widget paces itself: encoding and transmitting a panel costs milliseconds, and hover motion asks for it hundreds of times a second. Within a ~33 ms window, an unchanged-view render reuses the image already on screen — at most one window of crosshair staleness. A changed viewport or rectangle always renders. A loop that redraws per event stays responsive, because the redundant frames cost nearly nothing. The host should still drain its event queue before redrawing: read until poll(ZERO) is empty, then draw once. A burst of input collapses into one repaint of the final state, instead of queueing behind full frames. fred and the zoom example both do. Build with --release when pixels are on. A debug frame renders an order of magnitude slower.

What stays out#

The widget never reads the terminal. The core never sees input. There is no gesture configuration surface: a different policy means driving the physics directly. No animation. Time belongs to the host's loop. These boundaries keep a plot a value.