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.
This commit is contained in:
1 parent
f132a5ec92
commit
38ef459897
77 files changed
+5905
-688
No files matched your search
@@ -168,6 +168,133 @@ PLOT_TEST(a_drag_that_crosses_rows_is_ended_by_the_release_in_the_other_row) {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user