feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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

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.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+118
View File
@@ -352,7 +352,17 @@ PLOT_TEST(imgui_widget_invalid_position_does_not_move_the_range) {
// and not from the sentinel. This is asserted with the drag alive on purpose:
// nothing can move a range once the button is up, so the same check after the
// release could not fail and would prove nothing.
//
// The quiet frame before the second press is aged past the double-click window on
// purpose: this press lands in the same spot within the window of the first one,
// and the widget stamps presses with ImGui's clock, so without the age the two
// presses *are* a double-click -- the controller re-fits and begins no drag, which
// is the gesture the next test pins, not the sentinel behaviour this one is here
// for.
ImGuiIO& io = ImGui::GetIO();
io.DeltaTime = 0.4f;
f.frame(ImVec2(200.0f, 200.0f), false);
io.DeltaTime = 1.0f / 60.0f;
f.frame(ImVec2(200.0f, 200.0f), true);
const plot::Range second_press = f.x_range();
f.frame(ImVec2(-kMaxFloat, -kMaxFloat), true);
@@ -492,3 +502,111 @@ PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
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);
}
// --- keys -------------------------------------------------------------------
// A held key repeats. This is the adapter's own policy decision, not ImGui's
// default: ImGui edge-detects a press once, and `IsKeyPressed` only re-fires
// while the key is held when it is asked with repeat = true -- which the widget
// now does for every key it maps, because GLFW/Qt/GTK each deliver the OS's
// auto-repeat as extra presses and a held `+` or arrow must behave the same here
// (docs/embedding.md's shared policy). Held with `repeat = false`, the zoom
// happens exactly once; with `repeat = true`, once per repeat ImGui's timer
// fires, so the span keeps growing for as long as the key is down.
PLOT_TEST(imgui_widget_repeats_a_held_key) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
// A repeat clock a test can afford: first repeat one frame after the press,
// then one per frame, each frame a twentieth of a second of ImGui time.
io.DeltaTime = 0.05f;
io.KeyRepeatDelay = 0.05f;
io.KeyRepeatRate = 0.05f;
// Hover the plot (the keys are gated on hover) and press `-` once.
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_Minus, true);
f.frame(ImVec2(200.0f, 200.0f), false);
const double span_after_press = f.x_range().span();
PLOT_CHECK(span_after_press > 20.0); // one zoom-out detent on the {0, 20} fixture
// Hold it for half a second of ImGui time. Each repeat zooms again, so the
// span ends up several detents past the single press -- a single-fire widget
// would leave it exactly where the press left it.
for (int i = 0; i < 10; ++i) f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK(f.x_range().span() > span_after_press * 1.5);
io.AddKeyEvent(ImGuiKey_Minus, false);
f.frame(ImVec2(200.0f, 200.0f), false);
}
// The arrows pan and Escape cancels a drag, mapped to the controller's
// host-translated constants (`kKeyLeft`, `kKeyEscape`): one press of Left moves
// every x axis 10% of its span, and Escape hands the axes back the ranges they
// had when the drag began.
PLOT_TEST(imgui_widget_maps_arrows_and_escape) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
f.frame(ImVec2(200.0f, 200.0f), false);
const double span = f.x_range().span();
const double min_before = f.x_range().min;
// One Left press: every x axis slides by 10% of its span, and the span itself
// is untouched (a pan, not a zoom).
io.AddKeyEvent(ImGuiKey_LeftArrow, true);
f.frame(ImVec2(200.0f, 200.0f), false);
io.AddKeyEvent(ImGuiKey_LeftArrow, false);
f.frame(ImVec2(200.0f, 200.0f), false);
PLOT_CHECK_NEAR(f.x_range().span(), span, 1e-9);
PLOT_CHECK(std::abs(f.x_range().min - min_before) > 0.05 * span);
PLOT_CHECK(std::abs(f.x_range().min - min_before) < 0.15 * span);
// A drag that has panned, then Escape while the button is still down: the
// ranges go back to where they were when the drag began, and the drag is over.
const double before_drag = f.x_range().min;
f.frame(ImVec2(200.0f, 200.0f), true);
PLOT_CHECK(f.panning());
f.frame(ImVec2(260.0f, 200.0f), true);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) > 1e-6); // the pan moved it
io.AddKeyEvent(ImGuiKey_Escape, true);
f.frame(ImVec2(260.0f, 200.0f), true);
io.AddKeyEvent(ImGuiKey_Escape, false);
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
f.frame(ImVec2(260.0f, 200.0f), false); // the release ends nothing: already cancelled
PLOT_CHECK(std::abs(f.x_range().min - before_drag) < 1e-9);
}
// The widget stamps every pointer event with ImGui's clock, which is what makes
// the double-click -- two presses of the same button within 0.35 s and 4 px --
// exist on this host: re-fitting every axis is the controller's gesture, and the
// widget is the only supplier of the times it differences. Two quick presses on
// the fixture (whose x range was pinned to {0, 20}) must hand the axes back to
// the data -- the fit's own bounds -- and the second press must not begin a drag.
PLOT_TEST(imgui_widget_stamps_presses_so_a_double_click_can_reset) {
Fixture f;
PLOT_CHECK(f.init());
// One quiet hovered frame first. The very first frame's events are not the
// widget's yet: ImGui has not decided the hover (and its input queue trickles),
// so a press queued on frame 1 is swallowed by the hover gate and the controller
// never sees it. Every press this test means to deliver needs the hover live,
// which is the same reason the other tests here warm up with a hovered frame.
f.frame(ImVec2(200.0f, 200.0f), false);
f.frame(ImVec2(200.0f, 200.0f), true); // press 1
f.frame(ImVec2(200.0f, 200.0f), false); // release
// Press 2 in the same spot one frame later: ~1/60 s apart with the default
// DeltaTime, well inside the 0.35 s and 4 px window.
f.frame(ImVec2(200.0f, 200.0f), true);
const double fitted = f.x_range().span();
const double fitted_min = f.x_range().min;
PLOT_CHECK(fitted > 40.0); // the data's own span, not the fixture's {0, 20}
f.frame(ImVec2(220.0f, 200.0f), true); // a move with the second press's button
// ... begins no drag: the fit's values stand exactly.
PLOT_CHECK_NEAR(f.x_range().span(), fitted, 1e-9);
PLOT_CHECK_NEAR(f.x_range().min, fitted_min, 1e-9);
f.frame(ImVec2(220.0f, 200.0f), false);
}