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

+30 -17
View File
@@ -202,7 +202,7 @@ bool Renderer::init(Status* st) noexcept {
const render::UploadMode mode =
persistent ? render::UploadMode::PersistentMapped : render::UploadMode::Orphan;
font_backend_.reset(default_font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.device(style_.axis_font_px));
// The atlas is the backend's: `sync()` uploads whatever it exposes, at whatever
// size it says, and restores the host's unpack alignment around the upload
@@ -296,7 +296,7 @@ void Renderer::draw(Plot& plot) {
// The plot reserves its label strips from TextBackend metrics, so it must
// measure with the size the text pipeline draws with, and re-read the style
// each frame so a changed axis_font_px takes effect on the next one.
font_backend_.reset(default_font_, style_.axis_font_px);
font_backend_.reset(default_font_, style_.device(style_.axis_font_px));
// The cell every glyph staged this frame is drawn in, declared from the backend
// rather than left to whichever glyph is staged first: the instances carry rect
// origins, and the draw carries this size (see TextBackend::cell_uv).
@@ -327,6 +327,11 @@ void Renderer::draw(Plot& plot) {
// from the values the renderer draws with, so the label pad is never one number
// for the layout and another for the text placed inside it.
plot.set_style(style_);
// The plot's chrome is laid out in device pixels and it converts the style's
// logical metrics itself, so it needs the scale *before* `layout()`: this is the
// propagation point the contract names, and it is what keeps the strips the
// layout reserves and the strokes drawn into them at one scale.
plot.set_ui_scale(style_.ui_scale);
// One layout for the whole frame: autoscale, the strip fixed-point loop with
// its tick rebuilds, and the grid build all run in here, and every helper
// below reads the strips, the axes rect and the transforms it pins.
@@ -538,6 +543,10 @@ void Renderer::draw_grid(const Plot& plot) {
// transforms even when a trace uses another pair.
const bool log_x = plot.x_axis(0).log;
const bool log_y = plot.y_axis(0).log;
// A grid line is a size a user perceives, so it is one logical pixel wide at
// every DPI rather than a hairline on a 1x panel and a slab on a 2x one. (Spec §11
// gives the two classes a width each; until then both are this one logical pixel.)
const f32 grid_width = style_.device(1.0f);
// Two colour groups, one pass over the segments each: `LineBatch::clear()`
// keeps its capacity, so the second group re-uses the first one's storage.
@@ -547,7 +556,7 @@ void Renderer::draw_grid(const Plot& plot) {
if (line.major != majors) continue; // grid.build() already dropped non-finite ones
stage_segment(line.x0, line.y0, line.x1, line.y1, log_x, log_y);
}
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, 1.0f);
flush_lines(map, majors ? style_.grid_major : style_.grid_minor, grid_width);
}
}
@@ -566,13 +575,13 @@ void Renderer::draw_axis_lines(const Plot& plot) {
const Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
stage_segment(axis.range.min, y, axis.range.max, y, axis.log, log_y0);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.line_width_px);
flush_lines(mapping(plot, i, 0), style_.axis_line, style_.device(style_.line_width_px));
}
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
stage_segment(x, axis.range.min, x, axis.range.max, log_x0, axis.log);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.line_width_px);
flush_lines(mapping(plot, 0, i), style_.axis_line, style_.device(style_.line_width_px));
}
}
@@ -603,7 +612,7 @@ void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
const f32 width = font_backend_.measure(tick.label.c_str());
const f32 centre = static_cast<f32>(transform.to_screen_y(tick.value));
const f32 top = std::clamp(centre - 0.5f * line_h, layout.y_label_area.y, top_limit);
stage_text({layout.y_label_area.right() - style_.grid_label_pad - width, top},
stage_text({layout.y_label_area.right() - style_.device(style_.grid_label_pad) - width, top},
tick.label.c_str());
}
}
@@ -628,7 +637,7 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
// ones: keep the leading lines that fit and stage no more. A dropped title is
// chrome that had no room, which is the same bargain the label strips already make
// -- never drawn outside its strip, and never inside `axes`.
const f32 pad = style_.grid_label_pad;
const f32 pad = style_.device(style_.grid_label_pad);
const f32 line_h = font_backend_.height();
// X titles: the strip's rows top down, one per titled axis, each read left to
@@ -753,9 +762,9 @@ void Renderer::draw_cursors(const Plot& plot, const PlotLayout& layout) {
stage_segment(cursor.x, y_range.min, cursor.x, y_range.max, plot.x_axis(cursor.axis_x).log,
log_y);
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.line_width_px);
flush_lines(mapping(plot, cursor.axis_x, 0), cursor.color, style_.device(style_.line_width_px));
if (!cursor.label.empty()) {
stage_text({px + 3.0f, layout.axes.y + 2.0f}, cursor.label.c_str());
stage_text({px + style_.device(3.0f), layout.axes.y + style_.device(2.0f)}, cursor.label.c_str());
}
}
}
@@ -790,6 +799,10 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
const f64 x = transform.to_data_x(static_cast<f64>(px) + 0.5);
stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(a.axis_x).log, log_y);
}
// One device pixel, not a logical one: the band is a *fill* made of one
// stroke per framebuffer column (`px += 1.0f` above), so its width is the
// column pitch itself. Scaling it would overlap the columns it does not
// match and leave gaps in the ones it does.
flush_lines(mapping(plot, a.axis_x, 0), style_.measure_fill, 1.0f);
}
@@ -805,13 +818,13 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
std::snprintf(readout, kReadoutChars, "dx %s dy %s rms %s mean %s", dx, dy, rms, mean);
const f32 width = font_backend_.measure(readout);
const f32 limit = std::max(axes.x + 2.0f, axes.right() - width - 2.0f);
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + 2.0f, limit);
const f32 limit = std::max(axes.x + style_.device(2.0f), axes.right() - width - style_.device(2.0f));
const f32 left = std::clamp(0.5f * (x0 + x1) - 0.5f * width, axes.x + style_.device(2.0f), limit);
// The second line of the plot area, not the first: the first belongs to the
// cursor labels, and a wide readout clamped to the left edge would overprint
// them. The y axis title is not a consideration here -- it lives in its own
// strip outside the axes.
stage_text({left, axes.y + line_h + 2.0f}, readout);
stage_text({left, axes.y + line_h + style_.device(2.0f)}, readout);
}
}
@@ -825,11 +838,11 @@ void Renderer::draw_annotations(const Plot& plot) {
// A small cross pins the point; the caption sits to its right. Both are in
// framebuffer pixels, hence the identity mapping for the marker.
lines_.add_segment({px - 3.0f, py}, {px + 3.0f, py});
lines_.add_segment({px, py - 3.0f}, {px, py + 3.0f});
flush_lines(pixel_mapping(), annotation.color, 1.0f);
lines_.add_segment({px - style_.device(3.0f), py}, {px + style_.device(3.0f), py});
lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)});
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
if (!annotation.text.empty()) {
stage_text({px + 5.0f, py - 0.5f * line_h}, annotation.text.c_str());
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
}
}
}
@@ -859,7 +872,7 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) {
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.axis_line, 1.0f);
flush_lines(pixel_mapping(), style_.axis_line, style_.device(1.0f));
}
for (const LegendRow& row : legend.rows) {