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.
344 lines
17 KiB
C++
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
|