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.
10 KiB
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 toPlotLayout'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.