Files
plotter/docs/styling.md
T
Martino Ferrari fba8d985cf feat(cursor): a cursor's stroke is its own -- width and dash
Cursors took `Style::line_width_px` and were always solid, so a host had no way to place a
*readout mark* on the data (a measured level, a peak, a pulse's edge) without it reading as
another instrument framing the plot. `set_cursor_stroke(id, width, dash)` gives it its own, the
same shape as `set_cursor_color()`: per cursor, because one plot holds both kinds.

`width <= 0` means the style's default for that cursor (`handle_width_px` while hovered or
grabbed, `line_width_px` otherwise), and a hover can only make the stroke *thicker* -- the cue
that the pointer has hold of something must not be the thing that hides it. `dash.off <= 0` is
solid, the convention a trace's, the crosshair's and the grid's dash already share.

Test: a_cursor_stroke_can_be_thinner_and_dashed -- a default cursor and a thin dashed one in the
same frame. The dashed column alternates ink and gap (153/151 at the 4/4 the test asks for)
while the solid one has no gaps; the ink laid down over each stroke's whole run of columns is
less for the thin one. That last assertion is deliberately *not* a single-column peak: a cursor
is a data position and is not snapped, so the solid stroke's ink split across two columns and
read *lighter* than the thin one -- which is how this test first failed.
2026-10-03 15:22:01 +02:00

10 KiB
Raw Blame History

Styling

Every colour and metric the renderer draws with lives in render::Style (include/plot/render/style.hpp). A caller sets fields on Renderer::style(); the next frame picks them up, because nothing here is baked into a shader.

Set colours and metrics on the renderer, not on the plot: Renderer::draw() copies its Style into the plot and hands it the ui_scale before it lays out, so the strips, tick densities and strokes the plot measures with are the values the renderer draws with. A plot drawn without a renderer lays out with whatever Plot::set_style() was given.

The unit rule: logical versus device

A metric a user perceives as a size — a font, a stroke width, a padding, a marker, a swatch, the minimum spacing between ticks — is written in logical units, so a 12 px label is the same physical size on a 1x and a 2x panel. The host states its display with ui_scale (device pixels per logical unit); every consumer converts exactly once, through device():

f32 device(f32 logical) const noexcept {
  return ui_scale > 0.0f ? logical * ui_scale : logical;   // non-positive scale converts 1:1
}

Plot::layout() uses it to size the label strips, title strips and legend chrome; the renderer uses it to convert widths and font px (the font is reset at device(axis_font_px)).

The one exception is dense_threshold_spp, which is a density (samples per rasterised pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would make the chosen LOD level depend on the display rather than on the data, so it is used raw.

Style fields

Colours (neither logical nor device — nothing to scale):

field default meaning
background 0x101418 frame clear colour and legend/readout panel base
grid_major 0x39414C major (labelled-tick) grid lines
grid_minor 0x1C2126 at 50% alpha minor (subdivision) grid lines
axis_line 0x8A94A6 axis lines
axis_text 0xC8D0DC tick labels and axis titles
cursor 0xFFB020 cursor markers; also the hovered/grab highlight
measure_fill 0xFFB020 at α 0.15 the measurement band
crosshair 0xE6EBF2 crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour)

The scale:

field default unit
ui_scale 1.0 device pixels per logical unit; supplied by the host

Logical metrics (scaled by device()):

field default meaning
axis_font_px 12.0 logical em the axis text is drawn at
grid_label_pad 4.0 gap between a tick label and the axes, and the inset inside a title strip
grid_major_width 1.0 width of a major grid line
grid_minor_width 0.75 width of a minor grid line (sub-pixel on purpose)
grid_minor_dash_on 3.0 minor grid dash: ink length in logical pixels
grid_minor_dash_off 3.0 minor grid dash: gap; 0 draws the minor grid solid
grid_major_visible true stage the major grid at all
grid_minor_visible true stage the minor grid at all
line_width_px 1.5 width of the strokes framing the data (axis lines, cursor markers)
handle_width_px 5.0 width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound)
tick_min_px 60.0 minimum spacing between major ticks

Crosshair stroke and dash (logical):

field default meaning
crosshair_width 0.75 the crosshair's stroke width (deliberately thinner than a trace's 1.5)
crosshair_dash_on 4.0 ink length of the dash
crosshair_dash_off 4.0 gap length of the dash; 0 means solid

Device metric (never scaled):

field default meaning
dense_threshold_spp 1.5 samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples

The grid

The grid's positions come from the tick set alone: Grid::build() emits one GridLine per tick, spanning the perpendicular axis' full range, tagged major (a labelled tick) or not. Style decides only how each class is drawn — its width and visibility — and the two colours above.

A class switched off stages no geometry at all: the renderer skips it before the first segment is staged, so a dense plot's minor grid costs nothing rather than being drawn transparent. Grid therefore carries both classes' geometry whatever the style says; a caller that draws the grid itself applies the same two switches. Grid::vertical and Grid::horizontal (both true) control which directions emit lines at all.

The labelled class is the structure and the subdivisions are texture, and the minor grid's three defaults are what makes that read: it is thinner than the major hairline (grid_minor_width, a sub-pixel width that at 1x is expressed as coverage and on a HiDPI display as a narrower line), its colour carries an alpha so it blends into the background instead of sitting on it, and it is dashed (grid_minor_dash_on/_off) so an unlabelled line at an unlabelled position is never mistaken for a labelled one. Together those put a minor line at an order of magnitude less ink than a major one — at full opacity and the same width a dense minor grid reads as a second set of ticks. The dash is a fragment-shader discard, not extra geometry: the vertices are the line either way. Set grid_minor_dash_off = 0 for the old solid subdivision.

A cursor's stroke

set_cursor_color() gives a cursor its own colour and set_cursor_stroke() its own width and dash (Cursor::width, Cursor::dash). Per cursor rather than per plot, because a host uses cursors for two different jobs: the instruments that frame a reading — a marker at an edge, a pull handle — and the readout marks a computation places on the data (a measured level, a peak, a pulse's edges). Those want different weights. width <= 0 means the style's default for that cursor (handle_width_px while it is hovered or grabbed, line_width_px otherwise), and a hover can only make the stroke thicker: the cue that the pointer has hold of something must not be the thing that hides it. dash.off <= 0 (the default) is solid — the same convention as a trace's, the crosshair's and the grid's.

The crosshair's dash

crosshair_dash_on/crosshair_dash_off are the fragment shader's discard pattern, so no geometry is added. dash_off == 0 draws a solid line — the value every other line in the library uses, so the one pipeline serves them unchanged. A single plot may override all three knobs without touching the global Style through CrosshairStyle (below).

Legend

Legend (include/plot/legend.hpp), set with Plot::set_legend(). All sizes are logical, converted through Style::device() when Plot::layout() measures the panel.

field default meaning
visible true draw the legend at all (free when nothing is named)
place LegendPlace::TopRight corner of axes the panel is anchored to
frame true paint the panel (background at partial alpha plus a 1-px border)
pad 6.0 inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both)
swatch_w 14.0 swatch width
swatch_h 8.0 swatch height; the row height is max(swatch_h, line height)
row_gap 3.0 blank space between rows
margin 8.0 inset from the axes edge, and the budget the panel must fit in (so it drops entries rather than overhanging)

There is no "outside the plot" placement: the strips around axes belong to the tick labels and axis titles, and a legend that reserved space would resize the plot the moment a trace was named. A truncated legend ends with the constant kLegendEllipsis ("...").

CrosshairStyle

A per-Crosshair override of the three crosshair knobs, in logical pixels (include/plot/crosshair.hpp), set with Plot::set_crosshair_style(). Every field defaults to -1.0, which means "not overridden": the field falls back to the matching Style::crosshair_* metric, so a default-constructed CrosshairStyle is exactly the global style.

field default meaning
width -1.0 logical stroke width; < 0 takes Style::crosshair_width
dash_on -1.0 logical ink length; < 0 takes Style::crosshair_dash_on
dash_off -1.0 logical gap; < 0 takes Style::crosshair_dash_off, and 0 (once resolved) means solid

Traces

A trace's own width (TraceDesc::width, Trace::width) is logical too — a stroke like line_width_px, converted by the same device() call. Its default colour is 0x4FA3FF and its default width 1.5. Region fill/edge colours are the region's own (Region::fill 0xFFB020 at α 0.15, Region::edge 0xFFB020); a cursor's colour is per-cursor (0xFFB020 by default).

Installing a text backend

The font a plot measures with and the renderer draws with is a render::TextBackend (include/plot/render/text.hpp): measure(), height(), ascent(), em_px(), glyph(), cell_uv(), atlas_pixels(), atlas_width(), atlas_height() and revision().

  • Plot::set_text_backend(const render::TextBackend*) sets the backend a plot lays its labels out with. It is borrowed, not owned, and may be null (then the label areas fall back to PlotLayout's defaults and no string is measured).
  • Renderer::set_text_backend(const render::TextBackend*) sets the face the renderer draws with. It is also borrowed and must outlive the frames.

Note the interaction: Renderer::init() binds render::EmbeddedMonoFont, and every Renderer::draw() installs that binding on the plot it is given — replacing whatever a host set with Plot::set_text_backend() (a debug build names the one it dropped). So installing a backend on the plot only takes effect for a plot no renderer draws. To draw with your own face (say a plot::text::TrueTypeFont from the optional plot::font_ttf target), install it with Renderer::set_text_backend(); the swap takes effect on the next frame, which re-reads the backend's metrics at style().device(axis_font_px) and re-uploads its atlas when the backend's revision() moved. Passing nullptr restores the embedded face.