feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size

This commit is contained in:
Martino Ferrari committed 2026-09-28 12:17:53 +02:00
1 parent 1f03631e98
commit 7500fc90d2
21 files changed
+708 -82

No files matched your search

+17
View File
@@ -50,6 +50,23 @@ enum class DragMode {
///
/// A drag that changes no range is harmless: a box-zoom selection under 4 px on
/// either side is a click, and leaves the ranges alone.
///
/// ## Tolerances are logical, the coordinates they are compared against are not
///
/// The plot geometry the controller hit-tests in is device pixels (see
/// `PlotLayout`), and each adapter converts its pointer coordinates into the same
/// space before forwarding them. A tolerance written as a device constant would
/// therefore change its *physical* size with the display: a 5 px grab radius is
/// half the hand movement on a 2x panel, so the same cursor becomes harder to
/// catch there. Both tolerances -- the cursor grab radius and the box-zoom click
/// threshold -- are therefore logical constants multiplied by `Plot::ui_scale()`,
/// which is also why the controller reads the scale off the plot it already holds
/// instead of keeping a copy a host would have to keep in step.
///
/// The middle-drag axis zoom's per-pixel gain is deliberately *not* in that group:
/// it is a data-navigation sensitivity rather than a size (the wheel and `+`/`-`
/// are scale-free already, being counted in notches), and spec §12 reworks the
/// zoom and pan gestures that own it.
class Controller {
public:
explicit Controller(Plot& plot) noexcept : plot_(&plot) {}
+5
View File
@@ -20,6 +20,11 @@ enum class LegendPlace { TopLeft, TopRight, BottomLeft, BottomRight };
/// All of it is deliberately coarse: the size, the row heights and the panel's
/// extent come from the text backend's metrics and the names themselves, never from
/// a caller's arithmetic, so a legend can only be *placed* and never mis-measured.
///
/// The sizes below are **logical** units, like every other metric a user
/// perceives as a size (see the unit rule on `render::Style`): `Plot::layout()`
/// converts each through `Style::device()` before it measures the panel, so a
/// legend is the same physical size on a 2x panel as on a 1x one.
struct Legend {
/// Draw the legend at all. This is on by default and costs nothing to leave on:
/// a legend with no entries -- no trace named, or no backend to measure a name
+14 -3
View File
@@ -17,9 +17,11 @@
namespace plot {
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
/// `Rect` (origin top-left). `axes` is the area data is drawn into; the label
/// areas are the strips the tick labels live in and the title areas the strips
/// the axis titles live in, each outside `axes` and so never over the data.
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
/// what the renderer draws with, already converted from the logical metrics in
/// `Style` (see the unit rule on `Style`). `axes` is the area data is drawn into;
/// the label areas are the strips the tick labels live in and the title areas the
/// strips the axis titles live in, each outside `axes` and so never over the data.
///
/// `y_*` strips are left of `axes` and `x_*` ones below it, whatever an axis'
/// `place` says: an axis placed on the opposite edge still has its labels and
@@ -86,6 +88,15 @@ class Plot {
/// uses the renderer's values.
void set_style(const render::Style& s) noexcept { style_ = s; }
const render::Style& style() const noexcept { return style_; }
/// Device pixels per logical unit: what every metric in `style()` means, and what
/// the controller's pointer tolerances are scaled by. Set from the renderer, which
/// owns presentation -- `Renderer::draw()` writes `style().ui_scale` here before it
/// lays the chrome out, so the strips and the strokes cannot disagree about the
/// scale -- and set directly by a host that lays a plot out itself, or by a
/// controller whose plot no renderer draws.
void set_ui_scale(f32 scale) noexcept { style_.ui_scale = scale; }
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
f32 ui_scale() const noexcept { return style_.ui_scale; }
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
/// null -- then the label areas fall back to PlotLayout's defaults and no string
+3 -2
View File
@@ -52,8 +52,9 @@ class EmbeddedMonoFont final : public TextBackend {
/// Top of the cell to the baseline, in font pixels.
f32 ascent() const override;
/// `kEmPx`: the size the face was baked at. The renderer scales a run by
/// `Style::axis_font_px / em_px()`, so the same backend draws the size the
/// style asked for whatever it was baked at.
/// `Style::device(Style::axis_font_px) / em_px()` -- the style's logical em in
/// device pixels over this em -- so the same backend draws the size the style
/// asked for, on any DPI, whatever it was baked at.
f32 em_px() const override;
/// The glyph for `codepoint`: its atlas cell as a normalised rect, no extra
+22 -10
View File
@@ -91,13 +91,20 @@ class Renderer {
/// Draws `plot`, refreshing its layout, ticks and grid first.
///
/// The renderer owns the presentation, style and text both: `draw()` copies
/// `style()` into the plot and installs its own font backend on it, so the strips
/// `style()` into the plot, hands it the style's `ui_scale` *before* it lays out
/// (`Plot::set_ui_scale()`) and installs its own font backend on it, so the strips
/// and tick densities the plot lays out with are the values the renderer draws
/// with -- set colours and metrics here, not on the plot, and expect a backend
/// set with `Plot::set_text_backend()` to be replaced on the first frame (a
/// debug build names the one it dropped). The renderer must therefore outlive the
/// plot's use of it.
///
/// Every metric in `style()` is a logical unit, so this is also where the scale
/// becomes pixels: the font is reset at `style().device(axis_font_px)` and each
/// stroke and padding is converted through the same call. A host therefore states
/// its display once, in `style().ui_scale`, and the frame it gets back is the same
/// physical size on a 1x panel and a 2x one.
///
/// `Plot::layout()` is computed exactly once, here, and handed to every helper:
/// it runs the auto scale, the strip fixed-point loop and the grid build, so
/// re-deriving it per helper would rebuild the ticks and the grid per helper.
@@ -136,10 +143,13 @@ class Renderer {
/// pixel-pack alignment if `dump_frame_ppm()` ran.
void end_frame() noexcept;
/// The frame's colours and metrics; `draw()` hands them to the plot as well, so
/// the layout it computes matches what is drawn. `line_width_px` is the width of
/// the strokes framing the data (axis lines, cursor markers) -- grid lines stay
/// hairlines and every trace carries its own width.
/// The frame's colours and metrics; `draw()` hands them to the plot as well --
/// style and `ui_scale` both -- so the layout it computes matches what is drawn.
/// Every metric here is a logical unit (`Style`'s unit rule): the host writes
/// `ui_scale` from the display it is on and this converts each metric with
/// `device()`. `line_width_px` is the width of the strokes framing the data (axis
/// lines, cursor markers) -- grid lines stay hairlines and every trace carries its
/// own width.
render::Style& style() noexcept { return style_; }
const render::Style& style() const noexcept { return style_; }
@@ -162,11 +172,13 @@ class Renderer {
bool using_persistent_upload() const noexcept {
return line_vao_.persistent() && text_vao_.persistent();
}
/// The axis text size the renderer actually draws with: the on-screen cell
/// height of the backend it draws with. `Style::axis_font_px` is an em size, so
/// with an ascent-to-descent cell it is this that a host reads back as the line
/// height it got -- and it is the backend's own `height()` scaled, so it always
/// agrees with the metrics the plot laid the labels out with.
/// The axis text size the renderer actually draws with, in **device** pixels: the
/// on-screen cell height of the backend it draws with, which is
/// `style().device(Style::axis_font_px)`'s own cell rather than the logical request
/// itself. `Style::axis_font_px` is an em size, so with an ascent-to-descent cell
/// it is this that a host reads back as the line height it got -- and it is the
/// backend's own `height()` scaled, so it always agrees with the metrics the plot
/// laid the labels out with.
f32 axis_font_px() const noexcept { return font_backend_.cell_px(); }
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
+63 -10
View File
@@ -6,7 +6,29 @@ namespace plot::render {
/// Every colour and metric the renderer draws with. Defaults are the dark theme
/// the plan's render smoke test asserts against; a caller swaps a field and the
/// next frame picks it up, so nothing here is baked into a shader.
///
/// ## Units: every metric below is **logical**, and `device()` converts it
///
/// 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 panel and a 2x one
/// and a 1 px grid is the same hairline on both. The host states what its display
/// is with `ui_scale` (device pixels per logical unit) and every consumer converts
/// exactly once, through `device()`: `Plot::layout()` when it sizes the label
/// strips, the title strips and the legend's chrome, the renderer when it converts
/// widths and font px.
///
/// The one exception is `dense_threshold_spp`, which is a **density** rather than a
/// size: samples per *rasterised* pixel. It is compared against a samples-per-pixel
/// ratio computed from the framebuffer's own width, so scaling it would make the
/// chosen LOD level depend on the display and not on the data. It is used raw.
///
/// The same rule classifies the metrics that live outside this struct, which are
/// logical too and converted by the same call: `Legend`'s pad, swatch, gap and
/// margin, and the renderer's marker arm lengths and text offsets.
struct Style {
// --- colours: neither logical nor device, so nothing to scale ------------
Color background = Color::hex(0x101418);
Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
@@ -14,16 +36,47 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
f32 axis_font_px = 12.0f;
f32 grid_label_pad = 4.0f;
f32 line_width_px = 1.5f;
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel at or above
///< which a trace is gathered from the LOD
///< pyramid instead of from its raw samples.
///< It selects the gather level, never a
///< primitive: both sides draw through the one
///< instanced anti-aliased quad path.
f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks.
// --- the scale ----------------------------------------------------------
/// Device pixels per logical unit. 1.0 is the 1x display every default here was
/// chosen on. Supplied by the host (`Renderer::style()` is what an adapter
/// writes), and handed to the plot by `Renderer::draw()` before it lays out, so
/// layout and drawing cannot disagree about it.
f32 ui_scale = 1.0f;
// --- logical metrics: a size a user perceives, scaled by `device()` ------
f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at.
f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes,
///< and the inset inside a title strip.
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
///< (axis lines, cursor markers).
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
///< a 2x display gets the same tick *count*.
// --- device metric: a density, never scaled ------------------------------
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per **device** pixel at or
///< above which a trace is gathered from the LOD
///< pyramid instead of from its raw samples. A
///< density, not a size: it is deliberately not
///< scaled by `ui_scale`, or the chosen level
///< would move with the display's DPI. It selects
///< the gather level, never a primitive: both
///< sides draw through the one instanced
///< anti-aliased quad path.
/// `logical` in device pixels: the single conversion point for every logical
/// metric above, so the arithmetic appears once per consumer instead of a `*
/// ui_scale` scattered over the renderer and the layout.
///
/// A scale that is not positive is not a scale -- a host that has not measured
/// its display, a field nobody filled in -- and converts 1:1 rather than
/// collapsing every strip, font and stroke to nothing.
f32 device(f32 logical) const noexcept {
return ui_scale > 0.0f ? logical * ui_scale : logical;
}
};
} // namespace plot::render