From 7500fc90d2fe23efadcbb821c13881b2f3f9a475 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 12:17:53 +0200 Subject: [PATCH] feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size --- adapters/glfw/glfw_host.cpp | 6 + adapters/glfw/glfw_host.hpp | 5 +- adapters/gtk4/gtk_plot_area.cpp | 6 + adapters/gtk4/gtk_plot_area.hpp | 13 +- adapters/imgui/imgui_plot.cpp | 5 + adapters/qt6/qt_plot_widget.cpp | 5 + .../plans/2026-09-28-plot-dpi-grid-follow.md | 8 +- examples/gtk_demo.cpp | 8 +- include/plot/input/controller.hpp | 17 ++ include/plot/plot/legend.hpp | 5 + include/plot/plot/plot.hpp | 17 +- include/plot/render/embedded_mono_font.hpp | 5 +- include/plot/render/renderer.hpp | 32 ++- include/plot/render/style.hpp | 73 ++++- src/input/controller.cpp | 32 ++- src/plot/plot.cpp | 52 ++-- src/render/renderer.cpp | 47 ++-- tests/test_controller.cpp | 72 +++++ tests/test_imgui_widget.cpp | 37 +++ tests/test_plot.cpp | 95 +++++++ tests/test_render_smoke.cpp | 250 ++++++++++++++++++ 21 files changed, 708 insertions(+), 82 deletions(-) diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 540b129..12a7633 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -249,6 +249,12 @@ void Host::refresh_sizes() noexcept { fb_height_ = fh > 0 ? static_cast(fh) : 0u; if (ww > 0 && fw > 0) fb_scale_x_ = static_cast(fw) / static_cast(ww); if (wh > 0 && fh > 0) fb_scale_y_ = static_cast(fh) / static_cast(wh); + // The renderer draws its logical style metrics at this same ratio, so the chrome + // is the same physical size at any scale: one value states the display, and a + // 12 px label is 24 device px on the 2x panel both these sizes describe. Read here + // rather than in the frame loop because this is the one place the two sizes are, + // and re-read whenever the window moves to a display with another scale. + if (renderer_) renderer_->style().ui_scale = fb_scale_x_; } // ------------------------------------------------------------------ events diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index ac1b921..4e1915a 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -48,7 +48,10 @@ using FrameFn = std::function; /// `glfwGetWindowSize()` disagree. The host calls `begin_frame` with the /// framebuffer size and scales every pointer coordinate by the ratio /// `framebuffer / window`, so a gesture lands on the pixels the plot was laid out -/// in. On an unscaled display the ratio is 1.0 and nothing changes. +/// in. The same ratio states the display to the renderer +/// (`Style::ui_scale`), which is what makes the chrome -- labels, strokes, paddings +/// -- one physical size at any scale. On an unscaled display the ratio is 1.0 and +/// nothing changes. /// /// Headless smoke mode, read once per `run()`: /// diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index 7cbce14..dcfd3ad 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -303,6 +303,12 @@ void PlotArea::render() noexcept { last_render_s_ = started; if (frame_) frame_(*plot_, dt); + // The mapping's factor is the display's scale, so the renderer draws its logical + // style metrics at the same ratio the viewport and every forwarded coordinate are + // in: a 12 px label is the same physical size on a 1x and a 2x widget. It is the + // `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the + // ceiling under fractional scaling and would over-scale the chrome by up to 60%. + renderer_->style().ui_scale = mapping_.factor; renderer_->begin_frame(static_cast(viewport.w), static_cast(viewport.h), viewport); renderer_->draw(*plot_); renderer_->end_frame(); diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index 8492dd3..44d2251 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -110,11 +110,14 @@ using FrameFn = std::function; /// a delta is then a logical difference scaled by the factor in force now, never /// the difference of two device positions (see `DragAnchor`). /// -/// Note that this also means the renderer's *style metrics* stay in device -/// pixels: at scale 2 the label font is 24 device px = 12 logical px, so labels -/// are smaller relative to the widget than at 1x. That is the same convention -/// the GLFW and Qt adapters use, and a host that wants a constant logical text -/// size scales `Renderer::style()` metrics by the factor itself. +/// The same factor states the display to the *renderer*: it is written to +/// `Renderer::style().ui_scale` every frame, which is what makes the chrome the +/// same physical size at any scale. The style's metrics are logical (see the unit +/// rule on `render::Style` -- spec §10 reverses the older convention that had them +/// in device pixels and accepted smaller labels on a high-DPI host), so at scale 2 +/// the label font is 12 logical px drawn at 24 device px: the same size on screen as +/// at 1x, not a smaller one. This is the same convention the GLFW, Qt and ImGui +/// adapters use, and none of them has to scale a style metric by hand. /// /// ## Input /// diff --git a/adapters/imgui/imgui_plot.cpp b/adapters/imgui/imgui_plot.cpp index 33ab587..ce70da9 100644 --- a/adapters/imgui/imgui_plot.cpp +++ b/adapters/imgui/imgui_plot.cpp @@ -119,6 +119,11 @@ void PlotWidget::draw(const char* id, Plot& plot, Vec2 size, Renderer& renderer) controller_->set_viewport(region); forward_input(hovered); + // The backend's framebuffer scale is the display's scale, so the renderer draws + // its logical style metrics at it: the same assertion above pins it positive, + // and the chrome is then the same physical size whatever surface the host + // renders into. + renderer.style().ui_scale = scale.x; const u32 fb_w = static_cast(fb_w_f + 0.5f); const u32 fb_h = static_cast(fb_h_f + 0.5f); renderer.begin_frame(fb_w, fb_h, region); diff --git a/adapters/qt6/qt_plot_widget.cpp b/adapters/qt6/qt_plot_widget.cpp index 7b3dbe5..ab457e0 100644 --- a/adapters/qt6/qt_plot_widget.cpp +++ b/adapters/qt6/qt_plot_widget.cpp @@ -179,6 +179,11 @@ void PlotWidget::paintGL() { last_paint_s_ = started; if (frame_) frame_(*plot_, dt); + // The same factor `device_viewport()` scales the widget by: it states the display + // for the renderer too, so the logical style metrics are drawn at this ratio and + // a 12 px label keeps its physical size on a 2x screen. Re-read every frame, like + // the viewport, so a window moved to another display is drawn at *its* scale. + renderer_->style().ui_scale = static_cast(devicePixelRatioF()); renderer_->begin_frame(static_cast(viewport.w), static_cast(viewport.h), viewport); renderer_->draw(*plot_); renderer_->end_frame(); diff --git a/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md b/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md index c63b629..42c6acc 100644 --- a/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md +++ b/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md @@ -8,7 +8,7 @@ **Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 10, 11, 12. Read the relevant section before each task. -**Priority:** these three come *before* the remaining tasks of `2026-09-28-plot-interaction-and-fonts.md` (drag-and-drop regions, `Figure`, the demo/verification pass, the optional TrueType target). Task A changes the units every later visual task is written in, so it goes first; Task B is small and independent; Task C unblocks the interaction work. +**Priority:** these three come *before* the remaining tasks of `2026-09-28-plot-interaction-and-fonts.md` (drag-and-drop regions, `Figure`, the demo/verification pass, the optional TrueType target). Task 1 changes the units every later visual task is written in, so it goes first; Task 2 is small and independent; Task 3 unblocks the interaction work. ## Global Constraints @@ -24,7 +24,7 @@ --- -### Task A: UI scale — consistent physical size across DPI +### Task 1: UI scale — consistent physical size across DPI **Files:** - Modify: `include/plot/render/style.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`, `include/plot/render/renderer.hpp`, `include/plot/input/controller.hpp` (doc), `src/input/controller.cpp` @@ -67,7 +67,7 @@ git add -A && git commit -m "feat(render): logical style metrics with a UI scale --- -### Task B: Major and minor grid as independent styled classes +### Task 2: Major and minor grid as independent styled classes **Files:** - Modify: `include/plot/render/style.hpp`, `include/plot/plot/grid.hpp`, `src/plot/grid.cpp`, `src/render/renderer.cpp` @@ -109,7 +109,7 @@ git add -A && git commit -m "feat(render): independent major and minor grid clas --- -### Task C: Live follow with relative zoom and mouse panning +### Task 3: Live follow with relative zoom and mouse panning **Files:** - Modify: `include/plot/plot/axis.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp` (only if the live edge needs exposing) diff --git a/examples/gtk_demo.cpp b/examples/gtk_demo.cpp index f40d020..b32926c 100644 --- a/examples/gtk_demo.cpp +++ b/examples/gtk_demo.cpp @@ -421,9 +421,11 @@ gboolean on_exit_timer(gpointer user_data) { const plot::Rect viewport = demo.area->device_viewport(); // The evidence that the layout was *recomputed and not scaled*: the y label - // strip is a number of *font pixels*, so it stays the same size while the axes takes - // the rest of the new allocation. Two things are checked, and neither is "the - // strip is the same number": + // strip is measured from the labels' font metrics -- a logical size, converted to + // device pixels at *this* display's scale (see `Style::ui_scale`) -- so at a + // constant scale it stays the same size while the axes takes the rest of the new + // allocation. Two things are checked, and neither is "the strip is the same + // number": // // * the axes and its strips tile the *current* viewport exactly -- a layout // left over from the old geometry would still tile the old one; diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index 1ea3a74..ff577b9 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -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) {} diff --git a/include/plot/plot/legend.hpp b/include/plot/plot/legend.hpp index 222677d..b189907 100644 --- a/include/plot/plot/legend.hpp +++ b/include/plot/plot/legend.hpp @@ -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 diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 5276f6b..388320e 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -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 diff --git a/include/plot/render/embedded_mono_font.hpp b/include/plot/render/embedded_mono_font.hpp index 2301dc9..d7ec2a4 100644 --- a/include/plot/render/embedded_mono_font.hpp +++ b/include/plot/render/embedded_mono_font.hpp @@ -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 diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index bb6168a..48d6514 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 03b79bb..99428fd 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -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 diff --git a/src/input/controller.cpp b/src/input/controller.cpp index f1f29b6..86c3c05 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -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(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; diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index d5cfb36..9c66aa3 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -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(titles) * text_->height() + 2.0f * style_.grid_label_pad; + return static_cast(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(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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 2782cb1..5d741e5 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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(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(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) { diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index db3d41f..df6ab00 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -358,3 +358,75 @@ PLOT_TEST(key_f_auto_fits_traces) { PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale PLOT_CHECK(p.y_axis(ay).auto_scale); } + +// --- tolerances are logical, coordinates are device pixels ------------------- + +/// The grab radius is a distance the user aims with, so it must be the same +/// *physical* distance at any DPI: the controller compares device-pixel coordinates +/// against `logical * Plot::ui_scale()`. The press positions below are the same +/// logical distance (0.9 and 1.1 of the 5 px radius) from the cursor at both scales, +/// so the same press grabs -- and the same press just outside does not. +PLOT_TEST(grab_radius_scales_with_dpi) { + for (const f32 scale : {1.0f, 2.0f}) { + plot::Plot p; + p.set_ui_scale(scale); + p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); + const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); + const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000)); + plot::input::Controller c(p); + c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); + PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6); + + const f32 sx = screen_x_of(p, ax, 50.0); + const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius + const f32 outside = 1.1f * 5.0f * scale; // logical: just past it + c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false, + false}); + PLOT_CHECK(c.hovered_cursor() == cid); + c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false, + false}); + PLOT_CHECK(c.hovered_cursor() == plot::kInvalid); + // ...and a ctrl-drag at that same logical distance opens a cursor move, which is + // the gesture the radius actually gates. + c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true, + false}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor); + } +} + +/// The box-zoom click threshold is a hand movement too: a selection the user +/// perceives as a few pixels is a click at any DPI, so both dimensions are compared +/// against `logical * Plot::ui_scale()`. +PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) { + for (const f32 scale : {1.0f, 2.0f}) { + plot::Plot p; + p.set_ui_scale(scale); + p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); + const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false}); + plot::input::Controller c(p); + c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale}); + const plot::Range before = p.x_range(ax); + + // 3 logical px on each side: a click, at both scales. + const f32 x0 = 100.0f * scale; + const f32 y0 = 50.0f * scale; + const f32 small = 3.0f * scale; + c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false}); + c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true, + false, false}); + c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false, + true}); + PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9); + PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9); + + // 5 logical px on each side: a box, at both scales. + const f32 large = 5.0f * scale; + c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false}); + c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true, + false, false}); + c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false, + true}); + PLOT_CHECK(p.x_range(ax).min > before.min); + PLOT_CHECK(p.x_range(ax).max < before.max); + } +} diff --git a/tests/test_imgui_widget.cpp b/tests/test_imgui_widget.cpp index 4c93d69..410dd35 100644 --- a/tests/test_imgui_widget.cpp +++ b/tests/test_imgui_widget.cpp @@ -452,3 +452,40 @@ PLOT_TEST(imgui_widget_paints_only_the_visible_part_of_a_clipped_child) { f.pixel(f.paint_min_x + 40u, f.paint_min_y + 40u, out); PLOT_CHECK(!f.is_host(out)); } + +/// The widget states the display's scale to the renderer, which is what makes the +/// chrome one physical size on any DPI: `io.DisplayFramebufferScale` is the same +/// factor the widget already maps points to framebuffer pixels by, so a host on a 2x +/// surface gets a 2x *device* font and a 2x device strip -- the same logical layout +/// as on a 1x one. +PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) { + Fixture f; + PLOT_CHECK(f.init()); + ImGuiIO& io = ImGui::GetIO(); + + io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); + f.frame(ImVec2(10.0f, 10.0f), false); + PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6); + const plot::f32 cell_1x = f.renderer.axis_font_px(); + const plot::f32 strip_1x = f.plot.layout().y_axis_width; + PLOT_CHECK(cell_1x > 1.0f); + PLOT_CHECK(strip_1x > 1.0f); + + // The same widget and the same logical size, on a 2x surface. + io.DisplayFramebufferScale = ImVec2(2.0f, 2.0f); + f.frame(ImVec2(10.0f, 10.0f), false); + PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 2.0f, 1e-6); + // The renderer hands it to the plot before laying out, so the *device* chrome + // doubles while the logical figure (device / scale) is what the 1x run measured. + PLOT_CHECK_NEAR(f.plot.ui_scale(), 2.0f, 1e-6); + PLOT_CHECK_NEAR(f.renderer.axis_font_px(), 2.0f * cell_1x, 1e-3); + PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, 2.0f * strip_1x, 0.05f); + + // Back to 1x: the scale is re-read every frame rather than latched at the first + // one, so a window moved to another display follows. + io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f); + f.frame(ImVec2(10.0f, 10.0f), false); + PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6); + PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3); + PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index e89af23..c0d4716 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -25,6 +25,16 @@ f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t) return w; } +/// Every major tick's value, in order: what a tick-density assertion compares, so it +/// can compare the *grid* the two scales produce rather than only a count. +std::vector major_tick_values(const plot::Axis& a) { + std::vector v; + for (const plot::Tick& tick : a.ticks) { + if (tick.major) v.push_back(tick.value); + } + return v; +} + /// A log axis must never be left inverted or non-positive by an auto-fit. void check_log_axis_range(const plot::Range& r) { PLOT_CHECK(std::isfinite(r.min) && std::isfinite(r.max)); @@ -839,3 +849,88 @@ PLOT_TEST(legend_hidden_by_visible_false_draws_nothing) { scene.plot.set_legend(cfg); PLOT_CHECK(scene.plot.layout().legend.rows.size() == 3u); } + +// --- the UI scale: logical metrics, one physical size at any DPI ------------- + +/// A minimum tick spacing is a physical gap between labels, so the same *logical* +/// plot width must get the same major ticks at any scale. This is the failure mode +/// on the under-eager side: an unscaled `tick_min_px` measured against a 2x-wide +/// framebuffer packs twice as many ticks onto the same panel, and the grid's density +/// becomes a property of the display instead of the data. +PLOT_TEST(tick_spacing_is_physical) { + plot::render::EmbeddedMonoFont font; + + // No y axis, and so no y label strip: with a text backend present the axes width + // is then the viewport width *exactly* (`layout()` reserves a left strip only for + // the axes that have labels), and the two runs differ in the display's scale and + // nothing else. The strips' own scaling is the render smoke test's subject; this + // case isolates the tick rule. + plot::Plot one; + one.set_text_backend(&font); + one.set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax1 = one.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false}); + const plot::PlotLayout& lay1 = one.layout(); + PLOT_CHECK_NEAR(lay1.axes.w, 800.0f, 1e-6); + + plot::Plot two; + two.set_text_backend(&font); + two.set_ui_scale(2.0f); + two.set_viewport({0.0f, 0.0f, 1600.0f, 800.0f}); // the same plot, a 2x display + const u32 ax2 = two.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false}); + const plot::PlotLayout& lay2 = two.layout(); + PLOT_CHECK_NEAR(lay2.axes.w, 1600.0f, 1e-6); + + // Both axes are 800 logical units wide, so both must carry the same major ticks. + PLOT_CHECK_NEAR(lay2.axes.w / 2.0f, lay1.axes.w, 1e-6); + const std::vector majors1 = major_tick_values(one.x_axis(ax1)); + const std::vector majors2 = major_tick_values(two.x_axis(ax2)); + PLOT_CHECK(majors1.size() > 2u); // not vacuous: the case has ticks to lose + PLOT_CHECK(majors2 == majors1); + // The label strip is the same *logical* height too: its padding scaled even though + // this backend's own metrics are fixed. (A frame drawn by a renderer also gets + // metrics scaled with the display -- see the smoke test's two-scale case.) + PLOT_CHECK(lay2.x_label_area.h > lay1.x_label_area.h); +} + +/// The scale is an opt-in: nothing changes for a caller that never sets one, which +/// is what keeps this a unit change and not a behaviour change for existing code. +PLOT_TEST(ui_scale_defaults_to_one) { + plot::render::Style s; + PLOT_CHECK(s.ui_scale == 1.0f); + // At 1.0 the conversion is the identity, exactly -- no rounding, no reordering. + PLOT_CHECK(s.device(0.0f) == 0.0f); + PLOT_CHECK(s.device(1.5f) == 1.5f); + PLOT_CHECK(s.device(12.0f) == 12.0f); + PLOT_CHECK(s.device(1234.5f) == 1234.5f); + // A scale that is not a scale -- a host that has not measured its display -- is + // 1:1 rather than zero, which would collapse every strip and stroke to nothing. + s.ui_scale = 0.0f; + PLOT_CHECK(s.device(12.0f) == 12.0f); + s.ui_scale = -2.0f; + PLOT_CHECK(s.device(12.0f) == 12.0f); + + plot::render::EmbeddedMonoFont font; + // One plot left at the default and one told the scale is 1.0 lay out identically, + // measured strips and legend included: the default *is* 1.0. + plot::Plot plain; + plot::Plot one; + one.set_ui_scale(1.0f); + plot::data::ArraySeries series(ramp(8), 0.0, 1.0); + for (plot::Plot* p : {&plain, &one}) { + p->set_text_backend(&font); + p->set_viewport({0.0f, 0.0f, 800.0f, 400.0f}); + const u32 ax = p->add_x_axis({"t", {0.0, 7.0}, false, plot::AxisPlace::Bottom, false}); + const u32 ay = p->add_y_axis({"v", {0.0, 7.0}, false, plot::AxisPlace::Left, false}); + p->add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"}); + } + const plot::PlotLayout& a = plain.layout(); + const plot::PlotLayout& b = one.layout(); + PLOT_CHECK(plain.ui_scale() == 1.0f); + PLOT_CHECK(b.y_axis_width == a.y_axis_width); + PLOT_CHECK(b.x_axis_height == a.x_axis_height); + PLOT_CHECK(b.y_title_width == a.y_title_width); + PLOT_CHECK(b.axes.x == a.axes.x); + PLOT_CHECK(b.axes.h == a.axes.h); + PLOT_CHECK(b.legend.panel.w == a.legend.panel.w); + PLOT_CHECK(b.legend.panel.h == a.legend.panel.h); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 89464c8..6f62e04 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -2413,3 +2413,253 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) { name_ink); ctx.destroy(); } + +// --- one physical size at any DPI -------------------------------------------- + +namespace { + +/// The chrome one render reports, in **device** pixels. Both runs of the two-scale +/// tests below fill this from a frame they really drew, so a comparison of the two +/// figures is a comparison of two frames and not of two derivations of one rule. +struct ChromeFigures { + plot::f32 font_cell = 0.0f; ///< the on-screen text cell the frame drew with + plot::f32 y_label_w = 0.0f; ///< the reserved label strip + plot::f32 x_label_h = 0.0f; + plot::f32 y_title_w = 0.0f; + plot::f32 legend_w = 0.0f; + plot::f32 legend_h = 0.0f; + plot::f32 axes_w = 0.0f; + plot::f32 axes_h = 0.0f; + plot::u32 ink_run = 0u; ///< the widest run of label ink in the y label strip + /// Every figure above in logical units, i.e. divided by the scale the frame was + /// drawn at: the numbers that must agree across displays. + ChromeFigures logical(plot::f32 scale) const noexcept; +}; + +ChromeFigures ChromeFigures::logical(plot::f32 scale) const noexcept { + ChromeFigures l; + l.font_cell = font_cell / scale; + l.y_label_w = y_label_w / scale; + l.x_label_h = x_label_h / scale; + l.y_title_w = y_title_w / scale; + l.legend_w = legend_w / scale; + l.legend_h = legend_h / scale; + l.axes_w = axes_w / scale; + l.axes_h = axes_h / scale; + l.ink_run = static_cast(static_cast(ink_run) / scale + 0.5f); + return l; +} + +} // namespace + +/// The requirement this task exists for, measured rather than asserted by eye: the +/// same logical plot drawn on a 1x display and on a 2x one. One framebuffer holds +/// both passes -- a 1x plot is a 640x360 viewport in it, a 2x plot the whole thing, +/// exactly as an adapter embeds a plot in a widget -- so the two frames differ only +/// in `ui_scale` and in the device size that goes with it. +/// +/// The device figures must double. The logical ones -- the same figures divided by +/// the scale -- must be equal, which is what "the same physical size on any display" +/// means: the labels, strips, title column and legend occupy the same share of the +/// panel at both DPI, and each axis label is drawn with twice the pixels of ink. +PLOT_TEST(logical_metrics_are_dpi_independent) { + constexpr plot::u32 kBigW = 2u * kW; + constexpr plot::u32 kBigH = 2u * kH; + plottest::HeadlessContext ctx(kBigW, kBigH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + // The axes' border line runs down the label strip's right edge and is the same + // chalk-toned colour family as the text, so it is drawn in the background colour + // here: the only ink inside the strip is then the labels themselves. + r.style().axis_line = r.style().background; + + std::vector ys(256); + for (plot::u64 i = 0; i < ys.size(); ++i) { + ys[i] = static_cast(std::sin(static_cast(i) * 0.05)); + } + plot::data::ArraySeries s(std::move(ys), 0.0, 1.0); + + const auto figures_at = [&](plot::f32 scale) { + const plot::u32 w = static_cast(scale * static_cast(kW)); + const plot::u32 h = static_cast(scale * static_cast(kH)); + const plot::Rect vp{0.0f, 0.0f, static_cast(w), static_cast(h)}; + plot::Plot p; + p.set_viewport(vp); + const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom, + false}); + const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"}); + + r.style().ui_scale = scale; + // The framebuffer stays the big one for both runs: the plot occupies the top-left + // `vp` of it, which is where a sub-rect viewport puts it (and what the adapters + // pass), so the same readback arithmetic reads both. + r.begin_frame(kBigW, kBigH, vp); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + // The layout the renderer drew is the one the renderer laid out: `draw()` sets + // the plot's scale before `layout()`, so this is the propagation being tested. + PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6); + PLOT_CHECK_NEAR(p.text_backend()->height(), r.axis_font_px(), 1e-6); + + const plot::PlotLayout& lay = p.layout(); + ChromeFigures f; + f.font_cell = r.axis_font_px(); + f.y_label_w = lay.y_label_area.w; + f.x_label_h = lay.x_label_area.h; + f.y_title_w = lay.y_title_area.w; + f.legend_w = lay.legend.panel.w; + f.legend_h = lay.legend.panel.h; + f.axes_w = lay.axes.w; + f.axes_h = lay.axes.h; + const TextInk ink = + scan_text_ink(ctx, static_cast(lay.y_label_area.x), + static_cast(lay.y_label_area.right()), + static_cast(lay.y_label_area.y), + static_cast(lay.y_label_area.h), r.style().axis_text); + f.ink_run = ink.widest_row_run; + return f; + }; + + const ChromeFigures one = figures_at(1.0f); + const ChromeFigures two = figures_at(2.0f); + const plot::f32 k = 2.0f; + // Each run is normalised by *its own* scale -- the logical figures both displays + // must agree on. + const ChromeFigures one_l = one.logical(1.0f); + const ChromeFigures two_l = two.logical(k); + + // The device figures double: the same chrome, twice the pixels. + PLOT_CHECK_NEAR(two.font_cell, k * one.font_cell, 1e-3f); + PLOT_CHECK_NEAR(two.y_label_w, k * one.y_label_w, 0.02f); + PLOT_CHECK_NEAR(two.x_label_h, k * one.x_label_h, 0.02f); + PLOT_CHECK_NEAR(two.y_title_w, k * one.y_title_w, 0.02f); + PLOT_CHECK_NEAR(two.legend_w, k * one.legend_w, 0.02f); + PLOT_CHECK_NEAR(two.legend_h, k * one.legend_h, 0.02f); + PLOT_CHECK_NEAR(two.axes_w, k * one.axes_w, 0.02f); + PLOT_CHECK_NEAR(two.axes_h, k * one.axes_h, 0.02f); + + // ...and the logical figures agree, which is the requirement itself: a 640x360 + // panel and a 1280x720 one show the same logical plot. + PLOT_CHECK_NEAR(two_l.font_cell, one_l.font_cell, 5e-4f); + PLOT_CHECK_NEAR(two_l.y_label_w, one_l.y_label_w, 0.01f); + PLOT_CHECK_NEAR(two_l.x_label_h, one_l.x_label_h, 0.01f); + PLOT_CHECK_NEAR(two_l.y_title_w, one_l.y_title_w, 0.01f); + PLOT_CHECK_NEAR(two_l.legend_w, one_l.legend_w, 0.01f); + PLOT_CHECK_NEAR(two_l.legend_h, one_l.legend_h, 0.01f); + PLOT_CHECK_NEAR(two_l.axes_w, one_l.axes_w, 0.01f); + PLOT_CHECK_NEAR(two_l.axes_h, one_l.axes_h, 0.01f); + + // The ink is the pixels' own account: one drawn label occupies twice the device + // extent, and the same extent once normalised -- so the doubling above is drawn + // and not merely reserved. + PLOT_CHECK(one.ink_run > 8u); // labels really were drawn + PLOT_CHECK_NEAR(static_cast(two.ink_run), k * static_cast(one.ink_run), + 2.0f); + PLOT_CHECK(two_l.ink_run <= one_l.ink_run + 1u); + PLOT_CHECK(two_l.ink_run + 1u >= one_l.ink_run); + std::printf("[ui_scale] 1x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u;" + " 2x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u; logical" + " 1x %.2f/%.2f/%.2f/%.2fx%.2f/%u vs 2x %.2f/%.2f/%.2f/%.2fx%.2f/%u\n", + one.font_cell, one.y_label_w, one.x_label_h, one.y_title_w, one.legend_w, + one.legend_h, one.ink_run, two.font_cell, two.y_label_w, two.x_label_h, + two.y_title_w, two.legend_w, two.legend_h, two.ink_run, one_l.font_cell, + one_l.y_label_w, one_l.x_label_h, one_l.y_title_w, one_l.legend_h, one_l.ink_run, + two_l.font_cell, two_l.y_label_w, two_l.x_label_h, two_l.y_title_w, two_l.legend_h, + two_l.ink_run); + ctx.destroy(); +} + +/// `dense_threshold_spp` is a **density** -- samples per rasterised pixel -- and not a +/// size, so the display's scale must not move it. This is the failure mode on the +/// over-eager side: scaling the threshold with the panel would call the LOD pyramid +/// less readily at 2x, so the same data would be rasterised by a different path +/// depending on the display. +/// +/// The sample count grows with the device width, so both runs present the same +/// samples-per-device-pixel: if the threshold were scaled, the 2x run would fall back +/// to raw samples while the 1x one still took the pyramid. The threshold is set to a +/// value between them so the test can tell the two apart at all -- at the default 1.5 +/// the comparison is decided far from the threshold and any scale below 64x would be +/// invisible (`level_for` returns level 0 -- the same raw gather -- until a pixel +/// covers 64 samples). +PLOT_TEST(dense_threshold_stays_a_device_ratio) { + constexpr plot::u32 kBigW = 2u * kW; + constexpr plot::u32 kBigH = 2u * kH; + constexpr plot::f32 kThreshold = 1500.0f; + plottest::HeadlessContext ctx(kBigW, kBigH); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + + plot::Renderer r; + PLOT_CHECK(r.init(&st)); + r.style().dense_threshold_spp = kThreshold; + + const auto series_of = [](plot::u64 n) { + std::vector y(n); + for (plot::u64 i = 0; i < n; ++i) { + y[i] = static_cast(std::sin(static_cast(i) * 0.0001)); + } + return y; + }; + + struct Decision { + plot::u32 lod = 0u; + plot::u32 raw = 0u; + plot::f64 spp = 0.0; ///< samples per *device* pixel the frame was drawn at + plot::u32 level = 0u; ///< the pyramid level that density selects + }; + const auto decision_at = [&](plot::f32 scale) { + const plot::u32 w = static_cast(scale * static_cast(kW)); + const plot::u32 h = static_cast(scale * static_cast(kH)); + plot::data::ArraySeries s(series_of(static_cast(scale) * 1'000'000u), 0.0, 1.0); + plot::Plot p; + p.set_viewport({0.0f, 0.0f, static_cast(w), static_cast(h)}); + const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay}); + + r.style().ui_scale = scale; + r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast(w), static_cast(h)}); + r.draw(p); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + + Decision d; + d.lod = r.stats().lod_traces; + d.raw = r.stats().raw_traces; + // What the renderer actually drew at: the samples its gathered window covered + // over the *device* width of the axes it laid out. Recomputed here rather than + // taken on trust, so a spp divided by the logical width instead would show up as + // a doubled density and a different level. + const plot::f32 axes_w = p.layout().axes.w; + PLOT_CHECK(axes_w > 0.0f); + d.spp = static_cast(r.stats().visible_samples) / static_cast(axes_w); + d.level = s.pyramid().level_for(s.size(), d.spp); + return d; + }; + + const Decision one = decision_at(1.0f); + const Decision two = decision_at(2.0f); + + // The renderer took the pyramid at both scales... + PLOT_CHECK(one.lod == 1u && one.raw == 0u); + PLOT_CHECK(two.lod == 1u && two.raw == 0u); + // ...and the density that decision rests on is the same in device terms at both + // scales, which is what makes the level below identical rather than merely non-zero. + PLOT_CHECK_NEAR(two.spp, one.spp, 0.5); + PLOT_CHECK(one.spp > static_cast(kThreshold)); // not vacuous: over the line + PLOT_CHECK(one.level > 0u); // and a level, not the raw path + PLOT_CHECK(two.level == one.level); + std::printf("[dense] 1x spp %.1f -> level %u (lod %u raw %u); 2x spp %.1f -> level %u" + " (lod %u raw %u); threshold %.1f\n", + one.spp, one.level, one.lod, one.raw, two.spp, two.level, two.lod, two.raw, + static_cast(kThreshold)); + ctx.destroy(); +}