Files
plotter/tests/test_glfw_host.cpp
T
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00

318 lines
16 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GLFW host's event routing in figure mode: which row's controller a pointer pixel
// reaches.
//
// This is the adapter's own logic and not GLFW's -- a figure is several plots stacked in
// one framebuffer, and the host has to decide which of them a gesture was aimed at -- so it
// is tested here rather than through a window. Nothing below calls `run()`: the case needs
// no GL context, no display and no window, only the rects `Figure::layout()` computes and
// the controllers the host builds from them.
#include "harness.hpp"
#include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp>
namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
/// each row lays out as a plot rather than as an empty rect), handed to a host.
struct Routed {
explicit Routed(plot::u32 rows, plot::f32 gap = 0.0f) : figure(rows) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
figure.set_row_gap(gap);
for (plot::u32 row = 0; row < rows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
host.set_figure(&figure);
figure.layout();
}
plot::Figure figure;
plot::glfw::Host host;
};
} // namespace
PLOT_TEST(the_host_routes_a_pixel_to_the_row_it_is_in) {
Routed r(3u);
const std::vector<plot::Rect> rows = r.figure.layout().rows;
PLOT_CHECK(rows.size() == 3u);
for (plot::u32 row = 0; row < 3u; ++row) {
PLOT_CHECK(r.host.row_controller(row) != nullptr);
const plot::f32 x = rows[row].x + 0.5f * rows[row].w;
const plot::f32 y = rows[row].y + 0.5f * rows[row].h;
PLOT_CHECK(&r.host.controller_at(x, y) == r.host.row_controller(row));
}
// Above the first row and below the last one (the figure's own margins) are still the
// nearest row rather than a dropped event: a drag that leaves the data area must end
// where it should.
PLOT_CHECK(&r.host.controller_at(150.0f, 0.0f) == r.host.row_controller(0u));
PLOT_CHECK(&r.host.controller_at(150.0f, 599.0f) == r.host.row_controller(2u));
}
PLOT_TEST(the_host_routes_a_pixel_in_a_row_gap_to_the_nearest_row) {
// A gap is what `set_row_gap()` asks for. Rows tile the figure at the default of 0, so
// this is the only way a pixel can land in none of them -- and the routing must then
// answer with the row it is *near*, not with row 0, which is what a "first row that
// matches, else whatever `row` started at" loop would do.
Routed r(3u, 40.0f);
const std::vector<plot::Rect> rows = r.figure.layout().rows;
PLOT_CHECK(rows.size() == 3u);
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
const plot::f32 gap = rows[row + 1u].y - rows[row].bottom();
PLOT_CHECK(gap > 0.0f);
const plot::f32 middle = rows[row].bottom() + 0.5f * gap;
// Just past a row's bottom edge the gap pixel is that row's...
PLOT_CHECK(&r.host.controller_at(150.0f, rows[row].bottom() + 1.0f) ==
r.host.row_controller(row));
// ... and just above the next row's top edge it is the next row's.
PLOT_CHECK(&r.host.controller_at(150.0f, rows[row + 1u].y - 1.0f) ==
r.host.row_controller(row + 1u));
// The middle of the gap goes to the nearer of the two rows -- and, whatever it picks,
// to a row of *this* figure rather than to a default: this is the assertion the
// "first match, else whatever `row` started at" loop failed, since it answered row 0
// for every gap pixel.
plot::input::Controller& in_gap = r.host.controller_at(150.0f, middle);
PLOT_CHECK(&in_gap == r.host.row_controller(row) || &in_gap == r.host.row_controller(row + 1u));
PLOT_CHECK(&in_gap != r.host.row_controller(2u) || row + 1u == 2u);
}
}
// --- a drag that crosses rows is still one drag ------------------------------
namespace {
/// A two-row figure with a wide region on row 0, handed to a host: the fixture for the
/// drag capture. The region is what a drag edits, so where it ends up -- and whether the
/// owning controller still thinks it is dragging -- says whether the gesture that began on
/// it was the gesture the release ended.
struct DragRouted {
DragRouted() : figure(2u) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
plot::Region region;
region.x0 = 0.25;
region.x1 = 0.75;
region.snap = plot::RegionSnap::Free; // the bounds are the test's, not a sample's
region_id = figure.plot(0u).add_region(region);
host.set_figure(&figure);
figure.layout();
}
plot::Figure figure;
plot::glfw::Host host;
plot::u32 region_id = 0u;
};
} // namespace
/// The finding: `controller_at()` routes *every* pointer event by the row the pixel is in,
/// ignoring the row a drag began in. A press on row 0's region, a motion into row 1 and a
/// release in row 1 therefore left row 0's controller with `MoveRegion`, `drag_region_`,
/// `drag_cursor_` and `drag_fixed_x_` set for ever: it kept publishing the grabbed bound,
/// `grabbed_region()` reported a drag that had ended, and a later gesture re-entering row 0
/// resumed the stale edit from the old anchor. The routing now captures the press's row for
/// the button's whole life, so the motion and the release reach the controller that owns them.
PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) {
DragRouted r;
const std::vector<plot::Rect> rows = r.figure.layout().rows;
PLOT_CHECK(rows.size() == 2u);
plot::input::Controller& row0 = *r.host.row_controller(0u);
plot::input::Controller& row1 = *r.host.row_controller(1u);
// The screen x of data 0.5 on row 0's own x axis, and the middle of each row.
const plot::f32 x = static_cast<plot::f32>(r.figure.plot(0u).x_transform(0u).to_screen_x(0.5));
const plot::f32 y_a = rows[0].y + 0.5f * rows[0].h;
const plot::f32 y_b = rows[1].y + 0.5f * rows[1].h;
// Press on the region's body in row A.
r.host.feed_pointer_event({x, y_a, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::MoveRegion);
PLOT_CHECK(row0.grabbed_region() == r.region_id);
// Move into row B with the button still down, then release there.
r.host.feed_pointer_event(
{x + 4.0f, y_b, 4.0f, y_b - y_a, plot::input::Button::Left, 0u, true, false, false});
r.host.feed_pointer_event(
{x + 4.0f, y_b, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true});
// (a) No controller is left holding a drag mode -- the release ended the gesture on the
// controller that began it.
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None);
// (b) And nothing reports a grabbed handle any more.
PLOT_CHECK(row0.grabbed_region() == plot::kInvalid);
PLOT_CHECK(row0.grabbed_cursor() == plot::kInvalid);
PLOT_CHECK(row1.grabbed_region() == plot::kInvalid);
// (d) The region is where the release left it: the press below is in row B, so a motion
// re-entering row A must not edit it again from the old anchor. (Before the fix row A's
// stale `MoveRegion` resumed on exactly this motion and moved the region further.)
const plot::Region after = r.figure.plot(0u).region(r.region_id);
r.host.feed_pointer_event({x, y_b, 0.0f, 0.0f, plot::input::Button::Left, 0u, true, true, false});
r.host.feed_pointer_event({x + 20.0f, y_a, 20.0f, y_a - y_b, plot::input::Button::Left, 0u, true,
false, false});
r.host.feed_pointer_event(
{x + 20.0f, y_a, 0.0f, 0.0f, plot::input::Button::Left, 0u, false, false, true});
const plot::Region now = r.figure.plot(0u).region(r.region_id);
PLOT_CHECK(now.x0 == after.x0);
PLOT_CHECK(now.x1 == after.x1);
PLOT_CHECK(row0.drag_mode() == plot::input::DragMode::None);
}
// --- a second button does not steal or end the captured row -----------------
namespace {
/// A two-row figure handed to a host: the fixture for the capture's *button*. No
/// region this time -- what the test observes is which controller a press lands
/// on, and a pan is the easiest gesture to watch a plot's range move with.
struct TwoRows {
TwoRows() : figure(2u) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
for (plot::u32 row = 0; row < 2u; ++row) {
plot::Plot& p = figure.plot(row);
ax[row] = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
host.set_figure(&figure);
figure.layout();
}
plot::f32 row_y(plot::u32 row) const {
const plot::Rect r = figure.layout().rows[row];
return r.y + 0.5f * r.h;
}
plot::Figure figure;
plot::glfw::Host host;
plot::u32 ax[2] = {0u, 0u};
};
} // namespace
/// The finding (R5): the capture remembered only the *row*, so a second button
/// pressed while a drag ran stole nothing but its release still ended the capture
/// -- `pointer_target()` cleared `captured_` on **any** release. A chord (or a
/// trackpad that reports a second finger as a button) therefore stranded the first
/// button's gesture in its row: the next event of the *first* button was routed by
/// the pointer's position again, to a controller that never saw the gesture begin.
/// The capture now records the button as well, and only that button's release ends
/// it.
///
/// What the test observes is purely the host's routing -- which controller a fresh
/// press reaches -- and not the controller's drag modes, which a second button's
/// press/release pair legitimately rewrites: press-and-move pairs pan whichever
/// controller they are delivered to, so "whose range moved" says who got the events.
PLOT_TEST(a_second_button_neither_steals_nor_ends_the_captured_row) {
TwoRows r;
plot::input::Controller& row1 = *r.host.row_controller(1u);
const plot::f32 x = 150.0f;
// Left press in row 0: the capture is row 0's.
r.host.feed_pointer_event({x, r.row_y(0u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
// Right press in row 1 while the left gesture runs: the single-gesture policy --
// the capture is not stolen, so this press reaches row 0's controller too, and
// row 1's controller sees nothing at all.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, true,
true, false});
PLOT_CHECK(row1.drag_mode() == plot::input::DragMode::None);
// The right release must not end the left button's capture (the finding above):
// only the owning button's release does.
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Right, 0u, false,
false, true});
// The proof that the capture survived: a fresh LEFT press landing physically in
// row 1 is still routed to the captured row 0...
const plot::Range row0_before = r.figure.plot(0u).x_range(r.ax[0]);
const plot::Range row1_before = r.figure.plot(1u).x_range(r.ax[1]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
// ... so the drag it begins pans row 0 and leaves row 1 alone. (With the capture
// gone, this press-and-move belongs to row 1 and the two assertions swap.)
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) < 1e-9);
// The owning button's release ends the capture, wherever it lands...
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
// ... and the next press belongs to the row it lands in again: a fresh press in
// row 1 now pans row 1, not row 0.
const plot::Range row0_after = r.figure.plot(0u).x_range(r.ax[0]);
r.host.feed_pointer_event({x, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u, true,
true, false});
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 40.0f, 0.0f, plot::input::Button::Left, 0u,
true, false, false});
PLOT_CHECK(std::abs(r.figure.plot(1u).x_range(r.ax[1]).min - row1_before.min) > 1e-6);
PLOT_CHECK(std::abs(r.figure.plot(0u).x_range(r.ax[0]).min - row0_after.min) < 1e-9);
r.host.feed_pointer_event({x + 40.0f, r.row_y(1u), 0.0f, 0.0f, plot::input::Button::Left, 0u,
false, false, true});
}
// --- the row-rect cache ------------------------------------------------------
/// The finding (R7): `controller_at()` and `pointer_target()` read
/// `Figure::layout()` *per event*, and a non-const `layout()` recomputes -- every
/// pointer move on a multi-row figure re-laid every row out (ticks, grid, chrome
/// and all), twice for the readers that share a handler. The host now keeps the
/// rects of the last `refresh_row_rects()` and every handler reads that cache: the
/// run loop refreshes it once per frame, `set_figure()` fills it immediately (so a
/// scripted run routes against the figure it was just handed), and in between the
/// rows the handlers answer from are the rows the last frame drew -- at most one
/// frame stale across a resize, never a second layout.
PLOT_TEST(row_routing_reads_the_cache_until_it_is_refreshed) {
Routed r(3u);
const plot::Rect row1_before = r.figure.layout().rows[1];
const plot::f32 y = row1_before.y + 0.5f * row1_before.h;
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// Re-shape the figure without telling the host: the geometry the *next* frame
// would draw. The cache still holds the 600-pixel-tall rows, and the handler
// must keep answering from them -- with the old per-event layout the same pixel
// already answered for the new geometry (row 2), which is exactly what made
// every event pay for a relayout.
r.figure.set_viewport({0.0f, 0.0f, 300.0f, 300.0f});
r.figure.layout();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(1u));
// The scripted-run equivalent of the frame: the loop's one refresh per frame.
// From here the handlers answer with the new rects, and the pixel -- the old
// row 1's centre, now the bottom edge of a 300-pixel figure -- is row 2's.
r.host.refresh_row_rects();
PLOT_CHECK(&r.host.controller_at(150.0f, y) == r.host.row_controller(2u));
}
PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
plot::glfw::Host host;
PLOT_CHECK(host.figure() == nullptr);
PLOT_CHECK(host.row_controller(0u) == nullptr);
// With no figure the routing is the single-plot loop's: one controller for every pixel.
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
PLOT_CHECK(&host.controller_at(1000.0f, 1000.0f) == &host.controller());
plot::Figure figure(2u);
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
host.set_figure(&figure);
PLOT_CHECK(host.figure() == &figure);
PLOT_CHECK(host.row_controller(0u) != nullptr);
PLOT_CHECK(host.row_controller(2u) == nullptr); // a row that does not exist
// And clearing it goes back to the single-plot answer.
host.set_figure(nullptr);
PLOT_CHECK(host.figure() == nullptr);
PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
}