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;