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

+25 -7
View File
@@ -20,10 +20,15 @@ constexpr f64 kKeyZoomNotches = 1.0;
/// pixels, a different unit from a wheel notch, so it must not reuse
/// kZoomPerNotch: 200 px of drag is one decade, exp(-200 * ln10/200) == 0.1.
constexpr f64 kZoomPerPixel = std::numbers::ln10 / 200.0;
/// How close (in pixels) the pointer must be to a cursor to grab it.
constexpr f32 kCursorHitRadiusPx = 5.0f;
/// A box-zoom selection narrower than this on either side is a click.
constexpr f32 kMinBoxPixels = 4.0f;
/// How close the pointer must be to a cursor to grab it, in **logical** pixels: the
/// listener's aim is a physical distance, so the device-pixel radius it is compared
/// against is this times `Plot::ui_scale()` (see `grab_radius()`). An unscaled
/// constant would make grabbing physically harder -- half as forgiving -- on a 2x
/// display, which is the defect the scale exists to prevent.
constexpr f32 kCursorHitRadiusLogical = 5.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
@@ -39,6 +44,15 @@ f64 from_space(f64 v, bool log) noexcept {
/// key) zoom in, so the span shrinks by `exp(-notches * kZoomPerNotch)`.
f64 zoom_factor(f64 notches) noexcept { return std::exp(-notches * kZoomPerNotch); }
/// Device pixels per logical unit for the tolerances below. Read from the plot the
/// controller already holds -- every forwarded coordinate is in device pixels, and
/// the plot is the one that knows what a device pixel is worth -- rather than kept
/// as a copy that a host would have to update in step with the renderer's.
f32 tolerance_scale(const Plot& plot) noexcept {
const f32 scale = plot.ui_scale();
return scale > 0.0f ? scale : 1.0f;
}
/// True when a coordinate is one arithmetic can work with. Every event here comes
/// from a host, and a host can hand over anything: a toolkit's "no pointer"
/// sentinel, a NaN from a degenerate mapping of its own, an infinity from a lost
@@ -267,8 +281,11 @@ void Controller::box_zoom_update(const PointerEvent& e) {
void Controller::apply_box_zoom() {
const Rect sel = selection_;
// A selection under a few pixels on either side is a click: no zoom.
if (sel.w < kMinBoxPixels || sel.h < kMinBoxPixels) return;
// A selection under a few *logical* pixels on either side is a click: no zoom.
// The selection is measured in device pixels like everything the controller is
// handed, so the threshold is the logical one at this plot's scale.
const f32 min_box = kMinBoxLogical * tolerance_scale(*plot_);
if (sel.w < min_box || sel.h < min_box) return;
// Clamped to the axes rect the transforms are built from, so a drag that left
// the plot area cannot ask for data outside what the box covers on screen.
@@ -324,6 +341,7 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (!lay.axes.contains(px, py)) return kInvalid;
const f32 radius = kCursorHitRadiusLogical * tolerance_scale(*plot_);
u64 best = kInvalid;
f32 best_d = 0.0f;
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
@@ -331,7 +349,7 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
if (!cursor.visible) continue;
const f32 sx = static_cast<f32>(plot_->x_transform(cursor.axis_x).to_screen_x(cursor.x));
const f32 d = std::fabs(sx - px);
if (d > kCursorHitRadiusPx) continue;
if (d > radius) continue;
if (best == kInvalid || d < best_d) {
best = i;
best_d = d;
+32 -20
View File
@@ -322,7 +322,10 @@ const PlotLayout& Plot::layout() {
// which is precisely what stops this from fighting the range the user chose.
autoscale_axes();
const f32 pad = style_.grid_label_pad;
// The label padding is a logical size, so it is converted once, here: every strip
// and inset below reads this value in device pixels, like the backend metrics it
// is added to.
const f32 pad = style_.device(style_.grid_label_pad);
// Seeds: PlotLayout's defaults when nobody can measure text, otherwise the
// metrics the text backend does know (its line height, and just the padding
// until the first tick list says how wide the labels are).
@@ -398,9 +401,11 @@ const PlotLayout& Plot::layout() {
void Plot::refresh_ticks() {
// Ticks are generated for the final axes size: an x axis' density depends on
// the plot's width and a y axis' on its height.
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.tick_min_px, true);
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.tick_min_px, true);
// the plot's width and a y axis' on its height. The minimum spacing is a logical
// size, so it is converted here: a 2x display therefore gets the same *count* of
// major ticks over the same logical width rather than twice as many.
for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.device(style_.tick_min_px), true);
for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.device(style_.tick_min_px), true);
}
void Plot::build_grid() {
@@ -451,7 +456,7 @@ f32 Plot::y_title_strip_width() const {
if (text_ == nullptr) return 0.0f;
const u32 titles = titled_y_axis_count();
if (titles == 0u) return 0.0f;
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.grid_label_pad;
return static_cast<f32>(titles) * text_->height() + 2.0f * style_.device(style_.grid_label_pad);
}
f32 Plot::y_label_width() const {
@@ -463,7 +468,7 @@ f32 Plot::y_label_width() const {
widest = std::max(widest, text_->measure(tick.label.c_str()));
}
}
return widest + 2.0f * style_.grid_label_pad;
return widest + 2.0f * style_.device(style_.grid_label_pad);
}
void Plot::build_legend() {
@@ -474,9 +479,17 @@ void Plot::build_legend() {
// plot that lays out exactly as it did before legends existed.
if (text_ == nullptr || !legend_.visible) return;
const f32 pad = legend_.pad;
// Every one of these is a logical size -- the legend is chrome a user reads --
// while `text_->height()` and the name measures are device: converting each one
// once, into a local, is what keeps the panel's arithmetic in device pixels
// throughout, like the strips it is fitted into.
const f32 pad = style_.device(legend_.pad);
const f32 margin = style_.device(legend_.margin);
const f32 swatch_w = style_.device(legend_.swatch_w);
const f32 swatch_h = style_.device(legend_.swatch_h);
const f32 row_gap = style_.device(legend_.row_gap);
const f32 line_h = text_->height();
const f32 row_h = std::max(legend_.swatch_h, line_h);
const f32 row_h = std::max(swatch_h, line_h);
// Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and
// the panel's width after a drop is the width of an entry that is still listed.
legend_name_px_.clear();
@@ -492,8 +505,8 @@ void Plot::build_legend() {
if (entries == 0u) return;
const f32 ellipsis_w = text_->measure(kLegendEllipsis);
const f32 room_w = layout_.axes.w - 2.0f * legend_.margin;
const f32 room_h = layout_.axes.h - 2.0f * legend_.margin;
const f32 room_w = layout_.axes.w - 2.0f * margin;
const f32 room_h = layout_.axes.h - 2.0f * margin;
// Fit, from every entry down: the panel is a row per entry with `row_gap` between,
// and, once an entry has been dropped, one more row for the marker that says so.
@@ -510,8 +523,8 @@ void Plot::build_legend() {
const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w)
: legend_name_px_[keep - 1u];
const f32 rows = static_cast<f32>(keep + (truncated ? 1u : 0u));
panel_w = 2.0f * pad + legend_.swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * legend_.row_gap;
panel_w = 2.0f * pad + swatch_w + pad + widest;
panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap;
if (panel_w <= room_w && panel_h <= room_h) break;
if (--keep == 0u) return; // not even one entry and its marker fit
}
@@ -519,13 +532,13 @@ void Plot::build_legend() {
// Anchored in the corner `place` names and held `margin` off both edges, which is
// the inset the fit above was measured against -- so the panel is inside `axes` by
// construction and can never cross it, whatever corner it was put in.
f32 x = layout_.axes.x + legend_.margin;
f32 y = layout_.axes.y + legend_.margin;
f32 x = layout_.axes.x + margin;
f32 y = layout_.axes.y + margin;
if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) {
x = layout_.axes.right() - legend_.margin - panel_w;
x = layout_.axes.right() - margin - panel_w;
}
if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) {
y = layout_.axes.bottom() - legend_.margin - panel_h;
y = layout_.axes.bottom() - margin - panel_h;
}
legend.panel = Rect{x, y, panel_w, panel_h};
@@ -534,19 +547,18 @@ void Plot::build_legend() {
// The name box starts one swatch and one gutter in -- exactly what the panel's
// width allowed for -- and both boxes are centred in the row's height, so a swatch
// shorter than the text line is centred on it instead of sitting on its baseline.
const f32 name_x = x + pad + legend_.swatch_w + pad;
const f32 name_x = x + pad + swatch_w + pad;
f32 row_y = y + pad;
for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) {
const Trace& trace = traces_[i];
if (!trace.visible() || trace.name.empty()) continue;
LegendRow row;
row.trace = i;
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - legend_.swatch_h), legend_.swatch_w,
legend_.swatch_h};
row.swatch = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h};
row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h),
text_->measure(trace.name.c_str()), line_h};
legend.rows.push_back(row);
row_y += row_h + legend_.row_gap;
row_y += row_h + row_gap;
}
if (keep < entries) {
// Entries were dropped; the marker takes the row after the last kept one, with no
+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) {