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
@@ -26,7 +26,9 @@ using plot::u32;
|
||||
using plot::gtk4::DeviceMapping;
|
||||
using plot::gtk4::DragAnchor;
|
||||
using plot::gtk4::ScrollUnit;
|
||||
using plot::gtk4::ZoomAnchor;
|
||||
using plot::gtk4::button_from;
|
||||
using plot::gtk4::key_from;
|
||||
using plot::gtk4::kSurfacePixelsPerNotch;
|
||||
using plot::gtk4::mods_from;
|
||||
using plot::gtk4::scroll_notches;
|
||||
@@ -212,3 +214,106 @@ PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
|
||||
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
|
||||
PLOT_CHECK(!button_from(9u).has_value());
|
||||
}
|
||||
|
||||
// --- the pinch --------------------------------------------------------------
|
||||
|
||||
// `GtkGestureZoom` reports a cumulative scale since the gesture began, while the
|
||||
// controller's `GestureEvent` wants this frame's zoom factor; `ZoomAnchor` is the
|
||||
// fold, and it is the same fold the Qt adapter's `PinchTracker` does -- a
|
||||
// cumulative 1.0 -> 1.25 -> 1.5 spread is a 1.25 frame then a 1.2 frame, so the
|
||||
// zoom the plot applies is the zoom the fingers made.
|
||||
PLOT_TEST(gtk_zoom_anchor_folds_the_cumulative_scale_into_increments) {
|
||||
// A 2x widget: the focal point and its movement are reported in *device* pixels,
|
||||
// like every coordinate the controller sees.
|
||||
const DeviceMapping at_2 = DeviceMapping::from_sizes({400.0f, 300.0f}, {800.0f, 600.0f});
|
||||
plot::gtk4::ZoomAnchor zoom;
|
||||
|
||||
zoom.begin(1.0f, {100.0f, 75.0f});
|
||||
PLOT_CHECK(zoom.active);
|
||||
// The begin itself folds to "nothing happened yet", whenever the first
|
||||
// scale-changed arrives with the scale it began at.
|
||||
const plot::input::GestureEvent started = zoom.update(1.0f, {100.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(started.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(started.dy, 0.0, 1e-6);
|
||||
|
||||
// Fingers spread to 1.25 about a steady centre: one 1.25 zoom frame, anchored
|
||||
// at the centre in device pixels (100 * 2, 75 * 2).
|
||||
const plot::input::GestureEvent spread = zoom.update(1.25f, {100.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(spread.scale, 1.25, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.x, 200.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.y, 150.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dx, 0.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(spread.dy, 0.0, 1e-6);
|
||||
|
||||
// Spread on to 1.5 while the fingers drift 10 logical pixels right: the
|
||||
// increment is 1.2 and the pan is 20 device pixels.
|
||||
const plot::input::GestureEvent drifted = zoom.update(1.5f, {110.0f, 75.0f}, at_2);
|
||||
PLOT_CHECK_NEAR(drifted.scale, 1.2, 1e-6);
|
||||
PLOT_CHECK_NEAR(drifted.dx, 20.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(drifted.dy, 0.0, 1e-6);
|
||||
}
|
||||
|
||||
// The focal point is cached in logical widget pixels, so a factor change between
|
||||
// two updates (the window dragged to another display mid-pinch, GTK re-emitting
|
||||
// `::resize`) cannot read the ratio change as a focal jump; and an update outside
|
||||
// a gesture -- before any begin, or a straggler after the end -- zooms and moves
|
||||
// nothing rather than zooming from a made-up anchor.
|
||||
PLOT_TEST(gtk_zoom_anchor_stays_logical_and_ignores_stragglers) {
|
||||
const DeviceMapping at_1 = DeviceMapping::from_sizes({400.0f, 300.0f}, {400.0f, 300.0f});
|
||||
const DeviceMapping at_125 = DeviceMapping::from_sizes({400.0f, 300.0f}, {500.0f, 375.0f});
|
||||
plot::gtk4::ZoomAnchor zoom;
|
||||
|
||||
// The orphan update, before any begin.
|
||||
const plot::input::GestureEvent orphan = zoom.update(2.0f, {10.0f, 10.0f}, at_1);
|
||||
PLOT_CHECK_NEAR(orphan.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dx, 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(orphan.dy, 0.0, 1e-9);
|
||||
|
||||
zoom.begin(1.0f, {100.0f, 100.0f});
|
||||
// Four logical pixels of drift across a 1.0 -> 1.25 factor change: 4 * 1.25 =
|
||||
// 5 device pixels, and the anchor for the next frame is the new logical point.
|
||||
const plot::input::GestureEvent rescaled = zoom.update(1.0f, {104.0f, 100.0f}, at_125);
|
||||
PLOT_CHECK_NEAR(rescaled.scale, 1.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.dx, 5.0, 1e-6);
|
||||
PLOT_CHECK_NEAR(rescaled.x, 104.0 * 1.25, 1e-6);
|
||||
|
||||
// The gesture ends; a straggler scale-changed does nothing.
|
||||
zoom.end();
|
||||
PLOT_CHECK(!zoom.active);
|
||||
const plot::input::GestureEvent straggler = zoom.update(2.0f, {120.0f, 100.0f}, at_125);
|
||||
PLOT_CHECK_NEAR(straggler.scale, 1.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(straggler.dx, 0.0, 1e-9);
|
||||
}
|
||||
|
||||
// --- the keyboard -----------------------------------------------------------
|
||||
|
||||
// The whole key translation, now that it names arrows and Escape too. The
|
||||
// function keys are matched on the keyval, which for them is layout- and
|
||||
// level-independent; the printable keys on the Unicode the keyval decodes to,
|
||||
// which is what makes `+` and shift+`=` (and the keypad's plus) one key to the
|
||||
// controller. Anything else is `0` -- the caller hands the event back to GTK.
|
||||
PLOT_TEST(gtk_keys_map_by_keyval_first_and_unicode_second) {
|
||||
PLOT_CHECK(key_from(0xFF1Bu, 0u) == plot::input::kKeyEscape); // GDK_KEY_Escape
|
||||
PLOT_CHECK(key_from(0xFF51u, 0u) == plot::input::kKeyLeft); // GDK_KEY_Left
|
||||
PLOT_CHECK(key_from(0xFF53u, 0u) == plot::input::kKeyRight); // GDK_KEY_Right
|
||||
PLOT_CHECK(key_from(0xFF52u, 0u) == plot::input::kKeyUp);
|
||||
PLOT_CHECK(key_from(0xFF54u, 0u) == plot::input::kKeyDown);
|
||||
|
||||
// Printable keys, by code point -- the keyval is irrelevant to this branch.
|
||||
PLOT_CHECK(key_from(0x002Bu, static_cast<u32>('+')) == plot::input::kKeyZoomIn);
|
||||
PLOT_CHECK(key_from(0x003Du, static_cast<u32>('=')) == plot::input::kKeyZoomIn);
|
||||
PLOT_CHECK(key_from(0x002Du, static_cast<u32>('-')) == plot::input::kKeyZoomOut);
|
||||
PLOT_CHECK(key_from(0x005Fu, static_cast<u32>('_')) == plot::input::kKeyZoomOut);
|
||||
PLOT_CHECK(key_from(0x0066u, static_cast<u32>('f')) == plot::input::kKeyFit);
|
||||
PLOT_CHECK(key_from(0x0046u, static_cast<u32>('F')) == plot::input::kKeyFit);
|
||||
|
||||
// The keyval wins when both could match: the order is the seam's contract, not
|
||||
// an accident of GDK's tables.
|
||||
PLOT_CHECK(key_from(0xFF51u, static_cast<u32>('x')) == plot::input::kKeyLeft);
|
||||
|
||||
// And nothing a plot has no gesture for maps to a key: an unmapped keyval with
|
||||
// an unmapped code point is refused, and so is a bare zero.
|
||||
PLOT_CHECK(key_from(0x0078u, static_cast<u32>('x')) == 0);
|
||||
PLOT_CHECK(key_from(0u, 0u) == 0);
|
||||
}
|
||||
Reference in new issue
Block a user