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.
119 lines
5.7 KiB
C++
119 lines
5.7 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include <plot/core/types.hpp>
|
|
#include <plot/input/events.hpp>
|
|
#include <plot/math/vec2.hpp>
|
|
|
|
namespace plot::qt {
|
|
|
|
/// The cached pointer position a move event's movement is measured from, in
|
|
/// **logical** widget pixels.
|
|
///
|
|
/// Logical, not device, pixels on purpose. Qt reports every widget position in
|
|
/// logical pixels, and the device-pixel ratio can change between two move events
|
|
/// -- the window being dragged onto a monitor with another scale factor is the
|
|
/// case -- so an anchor cached in device pixels would compare a position computed
|
|
/// at the old ratio with one computed at the new one. At 1.0 -> 1.25 a logical
|
|
/// position of 500 would then look like a jump of 125 device pixels, and
|
|
/// `Controller::pan` applies a delta as a fraction of the range span (and clears
|
|
/// 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 ratio
|
|
/// in effect now makes the same gesture map to the same device distance whatever
|
|
/// the window's scale is.
|
|
///
|
|
/// A plain value with no toolkit dependency: the widget owns one, and this is the
|
|
/// part of the adapter's translation a test can drive directly (see
|
|
/// tests/test_qt_pointer.cpp).
|
|
struct PointerAnchor {
|
|
bool have = false; ///< false until the first sample, which has no movement
|
|
f32 x = 0.0f; ///< last logical position, widget pixels
|
|
f32 y = 0.0f;
|
|
|
|
/// Movement from the cached position to `logical`, in device pixels at `dpr`.
|
|
/// Zero before the first sample, so the first motion of a drag never includes
|
|
/// the jump to where the drag began.
|
|
Vec2 device_delta(Vec2 logical, f64 dpr) const noexcept {
|
|
if (!have) return Vec2{0.0f, 0.0f};
|
|
return Vec2{static_cast<f32>((static_cast<f64>(logical.x) - static_cast<f64>(x)) * dpr),
|
|
static_cast<f32>((static_cast<f64>(logical.y) - static_cast<f64>(y)) * dpr)};
|
|
}
|
|
|
|
/// Caches `logical` as the position the next movement is measured from. Always
|
|
/// logical pixels, whatever the ratio is: see the type's comment.
|
|
void set(Vec2 logical) noexcept {
|
|
x = logical.x;
|
|
y = logical.y;
|
|
have = true;
|
|
}
|
|
};
|
|
|
|
/// The state one pinch gesture is folded into `input::GestureEvent`s from.
|
|
///
|
|
/// Qt reports a pinch as **cumulative** values with no delta of its own:
|
|
/// `QPinchGesture::totalScaleFactor()` is the zoom since the gesture began, and
|
|
/// `centerPoint()` the focal point now, both absolute in the gesture's life. The
|
|
/// controller wants the opposite of both -- `GestureEvent::scale` is *this frame's*
|
|
/// zoom factor (1.0 = no change) and `(dx, dy)` the focal point's movement since the
|
|
/// previous gesture event -- so the widget owns one of these and folds each update:
|
|
/// the zoom arrives as the ratio of the two cumulative scales, the pan as the
|
|
/// difference of two focal points.
|
|
///
|
|
/// The focal point is cached in **logical** widget pixels, like `PointerAnchor`
|
|
/// above, for the same reason: `devicePixelRatioF()` can change mid-gesture (the
|
|
/// window dragged onto another display), and a cache in device pixels would compare
|
|
/// a point taken at the old ratio with one taken at the new one -- a phantom zoom
|
|
/// anchor movement, delivered to the controller as a pan. The difference of two
|
|
/// logical points, scaled by the ratio in force now, is the movement the fingers
|
|
/// actually made.
|
|
///
|
|
/// A plain value with no toolkit dependency: the widget owns one, and this is the
|
|
/// part of the adapter's pinch translation a test can drive directly (see
|
|
/// tests/test_qt_pointer.cpp).
|
|
struct PinchTracker {
|
|
bool active = false; ///< true between the gesture's begin and its end/cancel
|
|
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 Qt's first cumulative `scale` and logical focal point.
|
|
/// The first event must carry no zoom and no movement -- a pinch that begins has
|
|
/// zoomed nothing yet -- so this only records where the folding starts from.
|
|
void begin(f32 scale, Vec2 logical_center) noexcept {
|
|
active = true;
|
|
last_scale = scale;
|
|
x = logical_center.x;
|
|
y = logical_center.y;
|
|
}
|
|
|
|
/// Folds one update into an incremental `GestureEvent`, in device pixels at
|
|
/// `dpr`: the zoom about the focal point, and the focal point's movement since
|
|
/// the previous update. Zero zoom and zero movement before `begin()`, so an
|
|
/// update that arrives without a begin -- a host that missed the start -- is a
|
|
/// no-op rather than a zoom from a made-up anchor.
|
|
input::GestureEvent update(f32 scale, Vec2 logical_center, f64 dpr) noexcept {
|
|
input::GestureEvent g;
|
|
if (!active) return g;
|
|
// The frame's zoom factor: Qt's cumulative ratio over the one this tracker
|
|
// last folded. The first update after begin() divides the scale by itself,
|
|
// which is 1.0 -- a pinch that has not moved has zoomed nothing.
|
|
g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f;
|
|
g.x = static_cast<f32>(static_cast<f64>(logical_center.x) * dpr);
|
|
g.y = static_cast<f32>(static_cast<f64>(logical_center.y) * dpr);
|
|
g.dx = static_cast<f32>((static_cast<f64>(logical_center.x) - static_cast<f64>(x)) * dpr);
|
|
g.dy = static_cast<f32>((static_cast<f64>(logical_center.y) - static_cast<f64>(y)) * dpr);
|
|
last_scale = scale;
|
|
x = logical_center.x;
|
|
y = logical_center.y;
|
|
return g;
|
|
}
|
|
|
|
/// Ends the gesture: an update after this (a straggler `GestureUpdated` Qt
|
|
/// delivers out of order, a cancel that still reports) is again a no-op.
|
|
void end() noexcept { active = false; }
|
|
};
|
|
|
|
} // namespace plot::qt
|