Files
plotter/tests/test_gtk_input.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

320 lines
17 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GTK adapter's input translation, driven directly.
//
// Four pieces of it are this project's logic and not a toolkit's, and none of them
// shows up in a screenshot: the device-pixel mapping GTK's two sizes imply, the
// pixel space a drag anchor is kept in, the wheel's unit and sign, and the
// modifier mask. A headless X server delivers discrete wheel notches through XTEST
// (and only at an integer scale), so the surface branch of `scroll_notches()` and
// this session's fractional scales have no event to arrive on -- and the mask
// matters because GDK's state carries the mouse-button bits, which would otherwise
// ride into the controller's events.
//
// `gtk_input.hpp` names no GDK type (it mirrors the two scroll-unit values), so
// this compiles without GTK and without PLOT_BUILD_GTK4.
#include "harness.hpp"
#include <gtk4/gtk_input.hpp>
#include <cmath>
#include <optional>
using plot::f64;
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;
// GDK's positive `dy` is a scroll *south* (the wheel pushed away from the user) and
// a `ScrollUnit::Wheel` delta is already one unit per detent, so a notch up --
// `dy == -1.0`, what an X11 button-4 press arrives as -- has to leave as `+1.0`.
// Getting the sign wrong would invert every zoom, which looks like a working plot
// with an upside-down wheel.
PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) {
PLOT_CHECK_NEAR(scroll_notches(-1.0, ScrollUnit::Wheel), 1.0);
PLOT_CHECK_NEAR(scroll_notches(1.0, ScrollUnit::Wheel), -1.0);
PLOT_CHECK_NEAR(scroll_notches(-3.0, ScrollUnit::Wheel), 3.0);
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0);
}
// A smooth device reports surface pixels, and GDK defines no notch size for them
// ("surface pixels to scroll directly on screen" is its whole description), so the
// divisor is this adapter's *calibration* and not a conversion -- see
// kSurfacePixelsPerNotch, and note that it is uncalibrated against real hardware.
// What a test can hold is the reasoning rather than the number: the constant is
// what one notch is, the scaling is linear and unquantised (passing a pixel delta
// through unscaled would make a flick of a few hundred pixels tens of notches, a
// zoom no gesture can undo as fast as it happens), and the value is chosen so that
// a deliberate two-finger gesture -- of the order of 100 surface pixels -- is one
// detent rather than several.
PLOT_TEST(gtk_scroll_surface_deltas_are_scaled_by_the_calibration) {
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Surface), 1.0);
PLOT_CHECK_NEAR(scroll_notches(-2.0 * kSurfacePixelsPerNotch, ScrollUnit::Surface), 2.0);
PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -30.0);
// The calibration's own assumption, as an invariant that survives a re-tune: a
// deliberate gesture of 100 px is at most one detent, never several.
PLOT_CHECK(kSurfacePixelsPerNotch >= 100.0);
PLOT_CHECK(scroll_notches(-100.0, ScrollUnit::Surface) <= 1.0 + 1e-9);
// The sign is the wheel's, and a smooth device's tail is not quantised away: the
// scale is a pure division, so a 0.1-pixel event stays a 0.001-notch zoom
// (0.015% of the span). What the caller can refuse is an *exactly* zero amount:
// no zoom, so the event is not this widget's (see the adapter's `on_scroll`).
PLOT_CHECK_NEAR(scroll_notches(+kSurfacePixelsPerNotch, ScrollUnit::Surface), -1.0);
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch / 1000.0, ScrollUnit::Surface), 0.001,
1e-12);
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Surface), 0.0);
// The unit decides which path runs: a delta GDK reported in wheel units is one
// unit per detent however large it is, and is never divided by the calibration.
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Wheel),
kSurfacePixelsPerNotch);
}
// The device-pixel mapping is derived from GTK's two sizes -- the widget's logical
// allocation and the device size `GtkGLArea::resize` reports for the viewport its
// default handler sets -- and from no scale accessor. The defect this pins:
// `gtk_widget_get_scale_factor()` is documented to return "the next higher integer
// value" under fractional scaling, so an 800-logical-pixel widget whose viewport
// is 1000 device pixels (a 1.25 display) had its input multiplied by 2: a pointer
// at logical x=100 was forwarded as 200, 75 device pixels past where the FBO puts
// it, and `device_viewport()` claimed 1600 of them. The ratio of the two sizes is
// the space the renderer draws into, so it is the one input must use.
PLOT_TEST(gtk_device_mapping_is_the_ratio_of_the_two_sizes) {
const DeviceMapping quarter = DeviceMapping::from_sizes({800.0f, 600.0f}, {1000.0f, 750.0f});
PLOT_CHECK_NEAR(quarter.factor, 1.25, 1e-6);
PLOT_CHECK_NEAR(quarter.viewport.x, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.y, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.w, 1000.0);
PLOT_CHECK_NEAR(quarter.viewport.h, 750.0);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).x, 125.0, 1e-4);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).y, 250.0, 1e-4);
const DeviceMapping three_quarters =
DeviceMapping::from_sizes({800.0f, 600.0f}, {1400.0f, 1050.0f});
PLOT_CHECK_NEAR(three_quarters.factor, 1.75, 1e-6);
PLOT_CHECK_NEAR(three_quarters.viewport.w, 1400.0);
PLOT_CHECK_NEAR(three_quarters.viewport.h, 1050.0);
PLOT_CHECK_NEAR(three_quarters.to_device(400.0, 0.0).x, 700.0, 1e-4);
}
// At the integer scales the headless evidence runs at, the derived factor is the
// integer one -- 1600/800 is exactly 2, 800/800 exactly 1 -- so the mapping the
// previous code built from `gtk_widget_get_scale_factor()` is unchanged there, and
// only a fractional scale can tell the two derivations apart. A widget with no
// allocation has no ratio to take: it maps 1:1 and reports the (zero) size GTK
// gave, which is the state before anything is drawn or any event can arrive.
PLOT_TEST(gtk_device_mapping_is_unchanged_at_the_integer_scales) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({800.0f, 600.0f}, {800.0f, 600.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).x, 120.0, 1e-6);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).y, 40.0, 1e-6);
const DeviceMapping at_2 = DeviceMapping::from_sizes({800.0f, 600.0f}, {1600.0f, 1200.0f});
PLOT_CHECK(at_2.factor == 2.0f);
PLOT_CHECK_NEAR(at_2.viewport.w, 1600.0);
PLOT_CHECK_NEAR(at_2.to_device(120.0, 40.0).x, 240.0, 1e-6);
const DeviceMapping unallocated = DeviceMapping::from_sizes({0.0f, 0.0f}, {0.0f, 0.0f});
PLOT_CHECK(unallocated.factor == 1.0f);
PLOT_CHECK_NEAR(unallocated.viewport.w, 0.0);
PLOT_CHECK_NEAR(unallocated.viewport.h, 0.0);
}
// The drag anchor stays *logical* while the factor is re-derived per update: GTK
// re-emits `::resize` when the widget's scale-factor property changes, so a drag
// can span a scale change. Cached in device pixels, the same gesture reported
// `20 * 1.25 - 10 * 1.0 = 15` device pixels of movement where the pointer moved
// 12.5, and a position of `100 * 1.0 + 20 * 1.25 = 125` where the FBO puts it at
// 150. `Controller::pan` applies such a delta as a fraction of the range span and
// clears auto scale, so the phantom 2.5 pixels are a real pan of the plot and a
// live axis silently disarmed -- the same defect the Qt adapter's `PointerAnchor`
// pins for `QDpi` changes.
PLOT_TEST(gtk_drag_anchor_stays_logical_across_a_scale_change) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1000.0f, 800.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1250.0f, 1000.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_125.factor, 1.25, 1e-6);
DragAnchor anchor;
anchor.begin(100.0f, 100.0f);
PLOT_CHECK(anchor.have);
// The press point itself is reported in device pixels at the factor in force.
PLOT_CHECK_NEAR(anchor.position(at_1).x, 100.0, 1e-6);
PLOT_CHECK_NEAR(anchor.position(at_1).y, 100.0, 1e-6);
// First sample, before the move: the pointer moved 10 logical pixels.
const DragAnchor::Sample first = anchor.update(10.0, 0.0, at_1);
PLOT_CHECK_NEAR(first.delta.x, 10.0, 1e-6);
PLOT_CHECK_NEAR(first.position.x, 110.0, 1e-6);
// The window is now on the 1.25 display: the same gesture has moved 10 further
// logical pixels, so the movement is 10 * 1.25 = 12.5 device pixels and the
// position is (100 + 20) * 1.25 = 150, whatever the press was scaled by.
const DragAnchor::Sample second = anchor.update(20.0, 0.0, at_125);
PLOT_CHECK_NEAR(second.delta.x, 12.5, 1e-6);
PLOT_CHECK_NEAR(second.delta.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(second.position.x, 150.0, 1e-6);
// The release is the press point plus the offset `drag-end` reports, at the
// factor in force then -- not the last sample's position, and not an anchor
// cached in device pixels. It may be past the last update's offset, which is why
// `end()` takes its own.
PLOT_CHECK_NEAR(anchor.end(25.0, 0.0, at_125).x, 156.25, 1e-6);
PLOT_CHECK(!anchor.have);
}
// An amount the controller must refuse is not silently turned into a number here:
// `Controller::on_wheel` rejects a non-finite delta as a strict no-op, and that
// decision belongs to it alone.
PLOT_TEST(gtk_scroll_passes_a_non_finite_delta_through) {
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Wheel)));
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Surface)));
}
// GDK reports the button bits inside the same state word as the modifiers -- a
// release arrives with `GDK_BUTTON1_MASK` (1 << 8) set, a right-drag with `1 << 10`
// -- so the translation is a mask. Without it every drag's events would carry bits
// no other adapter's do.
PLOT_TEST(gtk_mods_ignore_the_mouse_button_bits) {
PLOT_CHECK(mods_from(0x100u) == 0u); // GDK_BUTTON1_MASK
PLOT_CHECK(mods_from(0x400u) == 0u); // GDK_BUTTON3_MASK
PLOT_CHECK(mods_from(0x501u) == static_cast<u32>(plot::input::Mod::Shift));
const u32 shift = static_cast<u32>(plot::input::Mod::Shift);
const u32 ctrl = static_cast<u32>(plot::input::Mod::Ctrl);
const u32 alt = static_cast<u32>(plot::input::Mod::Alt);
const u32 super = static_cast<u32>(plot::input::Mod::Super);
PLOT_CHECK(mods_from(1u) == shift); // GDK_SHIFT_MASK
PLOT_CHECK(mods_from(4u) == ctrl); // GDK_CONTROL_MASK
PLOT_CHECK(mods_from(8u) == alt); // GDK_ALT_MASK
PLOT_CHECK(mods_from(1u << 26) == super); // GDK_SUPER_MASK
PLOT_CHECK(mods_from(1u << 28) == super); // GDK_META_MASK, Command on macOS
PLOT_CHECK(mods_from(1u | 4u | 8u | (1u << 26)) == (shift | ctrl | alt | super));
// GDK_LOCK_MASK (CapsLock) is not a modifier the controller names.
PLOT_CHECK(mods_from(2u) == 0u);
}
// The gestures listen to "any button", so the numbers that reach the mapping are
// 1, 2, 3 and whatever the device reports. Only the three the controller has a
// gesture for are accepted: an unhandled button is dropped rather than defaulting
// to a left-drag pan.
PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
PLOT_CHECK(button_from(1u) == std::optional<plot::input::Button>(plot::input::Button::Left));
PLOT_CHECK(button_from(2u) == std::optional<plot::input::Button>(plot::input::Button::Middle));
PLOT_CHECK(button_from(3u) == std::optional<plot::input::Button>(plot::input::Button::Right));
PLOT_CHECK(!button_from(0u).has_value());
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);
}