Files
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

344 lines
17 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
#include <optional>
namespace plot::gtk4 {
/// GDK's scroll unit (`GdkScrollUnit`), mirrored here so this header -- and a
/// test that drives it -- needs no GTK include directory. `gtk_plot_area.cpp`
/// static-asserts that the two agree with GDK's own enumerators, so a GDK that
/// renumbered them would fail to compile rather than silently mis-scale a zoom.
enum class ScrollUnit {
Wheel = 0, ///< the delta is already in wheel detents, one per notch
Surface = 1, ///< direct-scroll surface pixels, with no notch size GDK defines
///< (see kSurfacePixelsPerNotch)
};
/// Surface pixels per wheel notch, for a `ScrollUnit::Surface` delta: a
/// **calibration**, not a unit conversion.
///
/// GDK documents the surface unit as "surface pixels to scroll directly on
/// screen" and defines no notch size for it, so nothing here converts one unit
/// into the other -- how far a smooth device zooms is a choice, and this is where
/// it is made. Its assumption is stated rather than borrowed from an authority:
/// one surface unit is one pixel of a direct-scrolling gesture, and a *deliberate*
/// two-finger gesture on a touchpad is of the order of 100 of them, so 100 surface
/// pixels are taken to be one notch -- the same step one wheel click gives. A
/// gesture of that size therefore zooms by one detent rather than by several,
/// which a smaller value would make it do; a much larger one would put a wheel's
/// own single step out of a touchpad's reach.
///
/// Being a sensitivity and not a conversion, it is uncalibrated against real
/// hardware: the pixels one flick delivers depend on the platform's pixel density
/// and on the gesture itself, and a value measured on a touchpad should replace
/// this one. What is *not* a choice is the unit an event arrives in: GDK reports
/// it per event, and a delta already in wheel units is never divided by this.
inline constexpr f64 kSurfacePixelsPerNotch = 100.0;
// --- the device-pixel mapping ---------------------------------------------
/// The device-pixel mapping of one widget allocation: the factor between the
/// **logical** pixels GTK reports geometry and input in and the **device** pixels
/// the GL area renders into, together with the device viewport itself.
///
/// Derived from the two sizes GTK supplies and from nothing else. The widget's
/// logical size is `gtk_widget_get_width()`/`get_height()`; the device size is
/// what `GtkGLArea::resize` reports, which is the size of the viewport the
/// signal's default handler sets (`glViewport (0, 0, width, height)` in
/// `gtk_gl_area_resize()`) and therefore the only rectangle this adapter's
/// renderer draws into. No scale accessor takes part:
///
/// * `gtk_widget_get_scale_factor()` is documented to return "the next higher
/// integer value" on a system with fractional scaling, so a 1.25 or 1.75
/// display rounds it to 2 -- an input coordinate multiplied by it names a
/// pixel the FBO does not have there;
/// * `gdk_surface_get_scale()` is the surface's real, fractional-capable ratio
/// and the cross-check to read this against, but it describes the *surface*,
/// not the FBO an area was given (GTK 4.22 sizes that FBO as the logical size
/// times the integer factor, i.e. `ceil(scale)`).
///
/// Deriving the factor from the reported device size instead keeps the viewport,
/// the renderer and every forwarded input coordinate on one rectangle by
/// construction: all three use this single value, and a GTK that sized the FBO by
/// some other rule would be followed rather than guessed at.
///
/// A plain value with no toolkit dependency, like the Qt adapter's
/// `PointerAnchor`: the widget owns one and the tests drive it directly.
struct DeviceMapping {
f32 factor = 1.0f; ///< device pixels per logical pixel
Rect viewport{}; ///< the device rectangle, with its origin at (0, 0)
/// From GTK's two sizes: the widget's logical allocation and the device size
/// `::resize` reported. The factor comes off the width -- a widget's device size
/// is its logical size times one ratio, so the two axes cannot disagree, and one
/// factor for both keeps a gesture's dx and dy in one space. `1.0` while there
/// is no logical width to divide by, which is a widget that has not been
/// allocated yet: nothing is drawn into it and no event can reach it.
static DeviceMapping from_sizes(Vec2 logical, Vec2 device) noexcept {
DeviceMapping mapping;
mapping.factor = logical.x > 0.0f ? device.x / logical.x : 1.0f;
mapping.viewport = Rect{0.0f, 0.0f, device.x, device.y};
return mapping;
}
/// A position (or a movement, which is the difference of two of them) in the
/// device pixels `viewport` is measured in. The one place the mapping is
/// applied, so a gesture lands on the pixel the renderer drew.
Vec2 to_device(f32 x, f32 y) const noexcept { return Vec2{x * factor, y * factor}; }
Vec2 to_device(double x, double y) const noexcept {
return to_device(static_cast<f32>(x), static_cast<f32>(y));
}
};
/// The `GtkGestureDrag` anchor: the press point and the last offset the gesture
/// reported, both in **logical** widget pixels.
///
/// Logical on purpose, for the reason the Qt adapter caches its own anchor
/// logically: `GtkGestureDrag` reports `start_x`/`start_y` and `offset_x`/`offset_y`
/// in logical pixels while the factor to device pixels is re-derived on every
/// `::resize`, and that factor changes when the window moves to a monitor with
/// another scale factor (GTK re-emits `::resize` when the widget's scale-factor
/// property changes). An anchor cached in device pixels would compare a position
/// taken at the old factor with one taken at the new one: at 1.0 -> 1.25, a
/// logical press point of 500 would look like a jump of 125 device pixels, and
/// `Controller::pan` applies a delta as a fraction of the range span (clearing
/// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth
/// of data. Keeping the anchor logical and scaling the *difference* by the factor
/// in force now makes the same gesture map to the same device distance whatever
/// the scale is.
struct DragAnchor {
bool have = false; ///< false until a drag begins, and again once it ends
Vec2 press{}; ///< logical press point, in widget pixels
Vec2 offset{}; ///< logical offset from `press` at the last sample
/// Begins a drag at the gesture's logical `start` point, with no offset yet.
void begin(f32 start_x, f32 start_y) noexcept {
press = Vec2{start_x, start_y};
offset = Vec2{0.0f, 0.0f};
have = true;
}
/// What a `drag-update` resolves to, in device pixels at the mapping in force.
struct Sample {
Vec2 position; ///< press point plus offset: where the pointer is now
Vec2 delta; ///< movement since the previous sample
};
/// Where the pointer is now, in device pixels at `mapping`: the press point plus
/// the offset of the last sample (`begin()`'s press point, before any update).
Vec2 position(const DeviceMapping& mapping) const noexcept {
return mapping.to_device(press.x + offset.x, press.y + offset.y);
}
/// One `drag-update`: where the pointer is and how far it moved since the
/// previous sample. The offset GDK reports is cached logical, and the movement is
/// a difference of two logical offsets scaled by one factor -- never the
/// difference of two device positions, which is what a factor change between the
/// two samples would corrupt.
Sample update(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
const Vec2 next{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 moved{next.x - offset.x, next.y - offset.y};
offset = next;
return Sample{mapping.to_device(press.x + next.x, press.y + next.y),
mapping.to_device(moved.x, moved.y)};
}
/// Ends the drag with the gesture's final offset, returning the position in
/// device pixels at `mapping`. `drag-end` carries the authoritative end of the
/// gesture -- the controller reads its position to finish a box zoom's selection
/// -- and it may follow the last `drag-update` by however far the pointer moved
/// since, so this offset is the one cached and used, not the last sample's.
Vec2 end(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
offset = Vec2{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 final_position = position(mapping);
have = false;
return final_position;
}
};
/// GTK's `GdkModifierType` bits as the controller's `input::Mod` mask.
///
/// A mask, not a reinterpretation: GDK's state carries the *mouse button* bits
/// as well (`GDK_BUTTON1_MASK` is `1 << 8`, and a release reports the button that
/// produced it), so the four modifiers the controller names are the only ones
/// that survive. Without the mask a right-drag would arrive at the controller
/// with an extra `0x400` bit that no other adapter's event carries.
inline u32 mods_from(u32 gdk_state) noexcept {
// The GDK bit values are repeated rather than included: this header names no
// GDK type, and the constants are fixed by GDK's public ABI. `gtk_plot_area.cpp`
// static-asserts them against GDK's own enumerators.
constexpr u32 kGdkShift = 1u << 0;
constexpr u32 kGdkControl = 1u << 2;
constexpr u32 kGdkAlt = 1u << 3;
constexpr u32 kGdkSuper = 1u << 26;
constexpr u32 kGdkMeta = 1u << 28; // maps to Command on macOS, Super here
u32 out = 0;
if ((gdk_state & kGdkShift) != 0u) out |= static_cast<u32>(input::Mod::Shift);
if ((gdk_state & kGdkControl) != 0u) out |= static_cast<u32>(input::Mod::Ctrl);
if ((gdk_state & kGdkAlt) != 0u) out |= static_cast<u32>(input::Mod::Alt);
if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) {
out |= static_cast<u32>(input::Mod::Super);
}
return out;
}
/// The button one of GTK's gestures reports, as the controller's enum.
///
/// Buttons 1, 2 and 3 are the only ones GTK names (left, middle, right) and the
/// only ones the controller has a gesture for. This adapter listens with
/// `gtk_gesture_single_set_button(gesture, 0)`, which GTK documents as "any
/// button", so a press from button 8 or 9 (the navigation buttons) reaches the
/// handler too: `nullopt` is returned for it, and the event is dropped before it
/// can begin a pan.
inline std::optional<input::Button> button_from(u32 gdk_button) noexcept {
switch (gdk_button) {
case 1u: return input::Button::Left;
case 2u: return input::Button::Middle;
case 3u: return input::Button::Right;
default: return std::nullopt;
}
}
/// A GDK scroll delta in wheel notches, positive = zoom in.
///
/// Two steps in one place: the unit a smooth device's delta arrives in, and the
/// sign. The unit is settled per event, exactly as GDK distinguishes it:
///
/// * a `ScrollUnit::Surface` delta is scaled by `1 / kSurfacePixelsPerNotch`,
/// which is a calibration rather than a conversion (see there); a
/// `ScrollUnit::Wheel` delta is already one unit per detent, which is GDK's
/// own definition of that unit;
/// * the *sign* -- GDK's positive `dy` is a scroll south, the wheel pushed away
/// from the user, while the controller's positive amount zooms in, so the
/// value is negated. A wheel notch up arrives as `dy == -1.0` and leaves here
/// as `+1.0`.
///
/// A non-finite delta is returned unchanged: `Controller::on_wheel` refuses it as
/// a strict no-op, which is the one place that decision belongs.
inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
if (unit == ScrollUnit::Surface) return -dy / kSurfacePixelsPerNotch;
return -dy;
}
/// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the
/// mapping below -- and a test that drives it -- needs no GTK include directory.
/// `gtk_plot_area.cpp` static-asserts each against GDK's own macro.
///
/// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym
/// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a
/// different keyval and deliberately unmapped, exactly as the GLFW adapter maps
/// the main-row arrows and not `GLFW_KEY_KP_4`.
constexpr u32 kGdkKeyEscape = 0xFF1Bu;
constexpr u32 kGdkKeyLeft = 0xFF51u;
constexpr u32 kGdkKeyUp = 0xFF52u;
constexpr u32 kGdkKeyRight = 0xFF53u;
constexpr u32 kGdkKeyDown = 0xFF54u;
/// One key-press translation, by keyval first and Unicode fallback second.
///
/// GTK names a key by its keyval, which is layout- and level-dependent: `+` is
/// usually shift+`=` and arrives as a different keyval, but both decode to the
/// same Unicode code point, which is why the printable keys go through
/// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all,
/// so they are matched on the keyval itself, before that fallback. `0` means "no
/// plot key": the caller returns the event to GTK unhandled.
///
/// The keypad's plus and minus decode to the same code points as the main row's,
/// so they come along for free -- the same set the GLFW adapter maps with
/// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`.
inline int key_from(u32 keyval, u32 unicode) noexcept {
switch (keyval) {
case kGdkKeyEscape: return input::kKeyEscape;
case kGdkKeyLeft: return input::kKeyLeft;
case kGdkKeyRight: return input::kKeyRight;
case kGdkKeyUp: return input::kKeyUp;
case kGdkKeyDown: return input::kKeyDown;
default: break;
}
switch (unicode) {
case static_cast<u32>(input::kKeyZoomIn):
case static_cast<u32>('='): return input::kKeyZoomIn;
case static_cast<u32>(input::kKeyZoomOut):
case static_cast<u32>('_'): return input::kKeyZoomOut;
case static_cast<u32>(input::kKeyFit):
case static_cast<u32>(input::kKeyFitUpper): return input::kKeyFit;
default: return 0;
}
}
/// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from.
///
/// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()`
/// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at
/// the start) and knows the focal point only as the bounding box of the pointer
/// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the
/// sense that it is where the fingers are *now*. The controller wants the
/// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal
/// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the
/// previous gesture event -- so the area owns one of these and folds each
/// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`).
///
/// The focal point is cached in **logical** widget pixels, like `DragAnchor` above,
/// for the same reason: the mapping's factor is re-derived on every `::resize`,
/// and a cache in device pixels would compare two points taken at different
/// factors. The difference of two logical points, scaled by the factor in force
/// now, is the movement the fingers actually made.
///
/// A plain value with no toolkit dependency, like every other type here: the area
/// owns one, and the tests drive it directly.
struct ZoomAnchor {
bool active = false; ///< true between the gesture's begin and its end
f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded
f32 x = 0.0f; ///< last focal point, logical widget pixels
f32 y = 0.0f;
/// Starts a gesture at its first cumulative `scale` and logical focal point.
/// Records only: a zoom that just began has zoomed and moved nothing yet.
void begin(f32 scale, Vec2 logical_center) noexcept {
active = true;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
}
/// Folds one `scale-changed` into an incremental `GestureEvent`, in device
/// pixels at `mapping`: the zoom about the focal point, and the focal point's
/// movement since the previous update. A no-op before `begin()`, so a
/// `scale-changed` that arrives without a begin -- a host that missed the start
/// -- zooms nothing rather than zooming from a made-up anchor.
input::GestureEvent update(f32 scale, Vec2 logical_center,
const DeviceMapping& mapping) noexcept {
input::GestureEvent g;
if (!active) return g;
// This frame's zoom: the cumulative ratio over the one last folded. The first
// update after begin() divides the scale by itself -- 1.0, no zoom, as it
// should be when the fingers have not moved apart yet.
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
const Vec2 device = mapping.to_device(logical_center.x, logical_center.y);
g.x = device.x;
g.y = device.y;
const Vec2 moved{logical_center.x - x, logical_center.y - y};
const Vec2 device_moved = mapping.to_device(moved.x, moved.y);
g.dx = device_moved.x;
g.dy = device_moved.y;
last_scale = scale;
x = logical_center.x;
y = logical_center.y;
return g;
}
/// Ends the gesture: a straggler update after this zooms nothing.
void end() noexcept { active = false; }
};
} // namespace plot::gtk4