The interaction release. A chart becomes an instrument without malevich ever
touching input: the core gains the physics — a queryable Mapping, a
Viewport that is pure domain arithmetic — and the ratatui widget grows the
controller: gestures, a crosshair that snaps to the data, linked panes that
share a window and mirror a cursor, pixel panels that answer the mouse with
atomic, self-pacing repaints. Bars finish the basic catalog on the way:
stacked, grouped, and floating bars are now plain compositions.
-
The widget becomes interactive — without malevich handling input. The
core gains the physics:Plot::mapping(&frame)exposes the resolved
geometry of a render as a queryable value (plot rectangle, cell ↔ data
both ways, per-cell data spans, values formatted the way the axes format
their labels);Viewportis zoom/pan as pure domain arithmetic over
x_domain/y_domain(decade space on log axes,tailfor streams),
seeded frommapping.viewport();stat::nearestsnaps a cursor to the
datum that exists. On top, the ratatui adapter grows aStatefulWidget:
PlotStatecaches the render's mapping for hit-testing, applies its
viewport on the next draw, and interprets the default gestures — hover
crosshair with an axis-formatted readout, wheel zoom anchored under the
cursor, left-drag pan, right-drag rubber-band zoom — from a
backend-neutralMousevocabulary the host feeds it (a six-line match
from crossterm, shown in the docs). The cursor snaps to the data: for
every point-backed line and points layer the readout lists the datum
nearest the cursor's x —label: value, axis-formatted, its cell
highlighted, gaps as—and never an interpolation — instead of the
cursor's own coordinates;snap(false)returns plain coordinates.
Overlays draw into the buffer only:
the plot value and the stateless widget render byte-identically as
before. Because a zoom is just a domain window, M4 re-aggregates to the
visible window every frame —cargo run --release --example zoom --features ratatuipans and zooms through millions of points with the
drawn line pixel-identical to plotting every point — measured: the new
widgetbench records a two-pane dashboard frame at 1.3 ms, a zoomed
ten-million-point frame at 18.6 ms, and a hovered one (the snap scan
included) at 25 ms on the BENCHMARKS.md baseline.docs/interaction.md
is the guide: the three-layer model, the gesture table, and the patterns
that need no library support — linked panes (a view is a value; sharing
it is assignment), selection → statistics (a rubber-band window
summarized with the ordinary stat vocabulary), follow-the-stream.
fredwears all of it on its series view: the full gesture set, a
year-over-year context strip linked to the main chart by mirroring the
x window, and a footer that describes the visible window when zoomed;
sysmonpins its streaming axes withtail, holding a stable
two-minute window while the rings fill.pan_left/pan_rightjoin the
keyboard sugar (h/l in fred, arrow keys in the zoom example), and
gestures stacked between renders compound instead of re-reading a stale
mapping — drag pans included, which an event-drained burst previously
collapsed to a single step. And with thepixelfeature, the interactive widget draws
real images:widget().graphics(g)reserves the area in the buffer
(skip cells, fresh ground on layout change) and stores the hybrid
chrome-plus-image block in thePlotState;Graphics::presentemits
the pending blocks afterterminal.drawin one synchronized write with
atomic kitty replacement,Graphics::retire/invalidate_pixelshandle
view switches. Interaction chrome upgrades to annotation marks drawn
into the image — anti-aliased crosshair rules, snap markers, in-panel
readout — with automatic axes pinned to the last frame so hovering
never jitters them (viewport-fixed axes are never pinned: the window a
gesture just set always renders). The pixel render paces itself: within a
~33 ms window an unchanged-view render reuses the image already on
screen, so hover floods and tick redraws cost nearly nothing and even
a one-event-per-frame host loop stops falling behind (a changed
viewport always renders — a zoomed window never shows stale). And
repaints never flicker and never rely on replacement semantics: each
panel's kitty image data travels transmit-only (a=t) under a stable
per-panel image id, and the presenter then creates a fresh placement
under an alternating placement id before retiring the one on screen —
create-before-delete in one synchronized write, so there is no gap on
any terminal, including those whose same-placement replacement
misbehaves; a panel whose content already matches the screen is not
transmitted at all;
Graphics::retiredeletes images by id, never touching other
applications'. fred
renders its series view this way wherever the terminal speaks sixel,
kitty, or iTerm2 — at native device-pixel density, so the image is
crisp on Retina cells (ptoggles pixel drawing live,--fasthalves
the density for slow links,--cellsforces glyphs from the start) — and both fred and the zoom example drain
their event queues before redrawing, collapsing input bursts into one
repaint. -
The hand-rolled deflate truncates every LZ77 match at 32 KiB
window-aligned output boundaries. Bounded blocks alone never protected a
streaming inflater — block output positions drift, so matches still
straddled the drain boundaries where Zig ≤0.15's flate (Ghostty ≤1.3.1)
aborts a fixed-Huffman transmission mid-match; a real chart stream
carried four hundred straddling matches. (Measured against Zig 0.15.2's
exact buffered decode path, those particular streams happened to
survive — the truncation is defense in depth against the documented
abort, not the explanation of any observed freeze.) Costs one shortened
match per 32 KiB (+0.2% on a measured chart stream); an emission-site
assertion and a five-window regression test pin the invariant. -
fred goes fully graphical: every view — the six-pane overview, the
linked series pair, distributions, the seasonality heatmap, relations —
renders as pixel panels through per-panePlotStates, presented and
retired as a unit on view switches, and every pane answers the mouse
(hover, wheel zoom, drags) through one generic router. The ink came
along: glowing lines over translucent washes filled from each series'
own floor (never a zero baseline that would crush GDP's axis),
accumulated-ink Phillips scatters, a dashed inversion rule, and the
seasonality heatmap gone bilinear — and the palette is designed, not
ANSI: a muted, luminance-balanced categorical set (validated for CVD
separation and 3:1 dark-surface contrast, assigned in fixed order),
a soft lavender for context panes, a reserved rose for recessions and
inversions, and the seasonality map diverging around zero, because
signed change is polarity, not magnitude. -
The interaction API, reviewed whole:
Mapping::plot_areaanswers with
a namedPanelvalue instead of a four-tuple;x_categories/
y_categoriesexpose a bands axis's labels (not a bare count);
y_span_atjoinsx_span_at;Plot::viewporttakes itsCopyvalue
by value; the pixel presenters becameGraphics::presentand
Graphics::retire, because the value that names the protocol owns its
choreography; and theMousevocabulary grewScrollLeft/ScrollRight
(horizontal swipes pan) behind#[non_exhaustive]input enums. -
Linked panes share a crosshair, not just a window.
Mapping::column_at(x)
is the x-only half ofcell_at, andPlotState::hover_x(x)places a
mirrored hover from a data x: the widget draws a vertical-only crosshair
at that pane's own column — an x but no honest row to claim — while
snapping and the readout work exactly as under a real cursor, in cells
and in pixel panels alike; a non-finite or off-window x clears the
mirror, and a real cursor placed later simply replaces it. fred mirrors
the cursor both ways on its series pair and sweeps one date crosshair
across all six overview panes. -
Graphics::economical()lifts the slow-link density trick pixel hosts
carried by hand: halve a Retina cell size, keep the ink weight through a
stroke override, a quarter of the bytes — sixel, with no placement
scaling, stays native. fred's--fastis now one call. -
Docs say the quiet parts.
hist2ddocuments its equal-length panic, and
the convenience presets that can panic on data (trend,hist2d,
heatmap) panic with the caller's mistake instead of "default options
are valid".Cells::rgbdiscloses that an image stretches to the plot
panel — shape the frame when shape matters. Terminology's Theme entry
records why the layer palette is theme state while the categorical
palette travels with the spec. -
The viewport's wire form carries only its windows: which space an axis
transforms in (decade or value) is derived when a viewport is seeded from
aMapping, never persisted — a stored flag could disagree with a plot
whose scale has since changed. A restored viewport is complete for
Plot::viewport, which reads only the windows; transform after
re-seeding, which is the gesture lifecycle anyway. The interaction guide
gains the modifier-gesture pattern — theMousevocabulary stays
modifier-free by decision (terminals report modifiers unevenly, and a
default grammar must not half-work per terminal), and a shift-wheel y
zoom is a five-line drive of the public physics.x_domain/y_domain
now say what they always did: bounds in either order, the axis always
ascends. -
Bars complete the catalog: a
basechannel (Bars::base) starts each bar
at a per-bar base instead of zero — barispansbase[i] .. base[i] + value[i], so the value keeps encoding the segment's length — and
Bars::at(x, width, values)centers bars at free numeric positions, on a
continuous axis or in band-index space on a bands axis. Stacked bars are
now a composition (each layer based on the running total; the low half of
stat::stackis exactly that, and the stacked render is asserted
cell-identical to a single layer of the totals), grouped bars are two
positioned layers offset around the band centers, and waterfalls fall out
of the same channel. A gap (NaN) in a base or a position skips that bar;
zero stays pinned into the y domain only while some bars layer still rises
from the zero baseline; the wire format is byte-stable for existing specs.
The gallery gainssegments, both compositions in one chart pair. -
Plot::mappingcosts a layout, not a render: the geometry pass now stops
after the extent probe and the layout it yields, instead of also running
the mapped M4 aggregation for a raster nobody draws. A ten-million-point
mapping drops from 30.4 ms to 2.1 ms on the BENCHMARKS.md baseline (new
plot/mapping_10m_80x20row), and a parity test pins the layout-pass
mapping to the render pass's, query for query. -
The design argument is public.
docs/now carries the vision and its five
rules, seven principle files — each arguing one constraint and ending in a
"Spelled today" section that may rot while the argument must not — and
guides for terminals, pixels, notebooks, performance, and serde.
TERMINOLOGY.mdandSERDE.mdmoved there (stubs remain at the old
paths). The gallery now reads as a ladder of sections, and principle files
demonstrate their claims with generated witness charts, spliced and
CI-verified like every chart in the docs.