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
+105
-11
@@ -11,6 +11,9 @@
|
||||
#include <QOpenGLContext>
|
||||
#include <QPointF>
|
||||
#include <QWheelEvent>
|
||||
#include <QEvent>
|
||||
#include <QGestureEvent>
|
||||
#include <QPinchGesture>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -93,6 +96,10 @@ PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
|
||||
// The controller's keys (+, - and f) are input to the plot, and a widget without
|
||||
// a focus policy never receives a key event.
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
// Two-finger zoom/pan arrives as one gesture, which no mouse-event handler sees:
|
||||
// grabbing it routes QEvent::Gesture here, and `event()` folds it into the
|
||||
// controller's `GestureEvent`s (zoom about the focal point, focal pan).
|
||||
grabGesture(Qt::PinchGesture);
|
||||
}
|
||||
|
||||
PlotWidget::~PlotWidget() {
|
||||
@@ -243,6 +250,10 @@ void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released
|
||||
e.down = any_plot_button(event->buttons());
|
||||
e.pressed = pressed;
|
||||
e.released = released;
|
||||
// Qt's input-event stamp is milliseconds since an arbitrary epoch (the system
|
||||
// monotonic clock, in practice); the controller only ever differences them, for
|
||||
// the double-click detector, so the epoch does not matter -- the unit does.
|
||||
e.time_s = static_cast<f64>(event->timestamp()) * 1.0e-3;
|
||||
controller_.on_pointer(e);
|
||||
event->accept();
|
||||
update();
|
||||
@@ -256,16 +267,26 @@ void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event,
|
||||
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
|
||||
|
||||
void PlotWidget::wheelEvent(QWheelEvent* event) {
|
||||
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
|
||||
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
|
||||
// pushed away from the user). Passing the raw delta would zoom 120x too far.
|
||||
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
|
||||
if (notches == 0.0f) {
|
||||
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
|
||||
// a zero-notch zoom changes no range but still runs the range setters, which
|
||||
// clear auto scale -- so a stray horizontal scroll would silently stop a live
|
||||
// axis from following its stream. Ignored, so an enclosing scroll area may
|
||||
// still act on it.
|
||||
// Two units arrive, one per axis: Qt's `angleDelta()` is in eighths of a degree,
|
||||
// 120 to a notch, while `pixelDelta()` is in (high-resolution) scroll pixels and
|
||||
// is the only non-zero delta a device that reports pixels produces. A pixel
|
||||
// delta has no notch size Qt defines, so the divisor is this adapter's
|
||||
// **calibration**, not a conversion: 40 scroll pixels are taken to be one notch,
|
||||
// the order of a deliberate one- or two-finger scroll gesture, so such a gesture
|
||||
// zooms about one detent instead of a fraction of one (the same reasoning, and
|
||||
// the same role, as the GTK adapter's kSurfacePixelsPerNotch; a value measured
|
||||
// on real hardware should replace it).
|
||||
const QPoint angle = event->angleDelta();
|
||||
const QPoint pixels = event->pixelDelta();
|
||||
const bool pixel_scrolled = angle == QPoint(0, 0) && !pixels.isNull();
|
||||
const f32 notches = pixel_scrolled ? static_cast<f32>(pixels.y()) / 40.0f
|
||||
: static_cast<f32>(angle.y()) / 120.0f;
|
||||
const f32 notches_x = pixel_scrolled ? static_cast<f32>(pixels.x()) / 40.0f
|
||||
: static_cast<f32>(angle.x()) / 120.0f;
|
||||
if (notches == 0.0f && notches_x == 0.0f) {
|
||||
// No zoom and no pan: the event moves nothing, so it must not reach the
|
||||
// controller -- a zero-delta event would still run the range setters and clear
|
||||
// auto scale. Ignored, so an enclosing scroll area may still act on it.
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
@@ -273,7 +294,10 @@ void PlotWidget::wheelEvent(QWheelEvent* event) {
|
||||
input::WheelEvent e;
|
||||
e.x = p.x;
|
||||
e.y = p.y;
|
||||
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
|
||||
// The horizontal delta is forwarded: the controller pans x by it (5% of the span
|
||||
// a notch) instead of zooming, which is what a sideways scroll on a trackpad
|
||||
// means -- and what every other adapter's events already let it do.
|
||||
e.dx = notches_x;
|
||||
e.dy = notches;
|
||||
e.mods = mods_from(event->modifiers());
|
||||
controller_.on_wheel(e);
|
||||
@@ -292,6 +316,16 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
|
||||
case Qt::Key_Minus:
|
||||
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
|
||||
case Qt::Key_F: mapped = input::kKeyFit; break;
|
||||
// Arrows pan (10% of the span per press, repeats included) and Escape cancels
|
||||
// the active drag -- translated, like the ASCII set above, and the controller
|
||||
// decides what each does. Not offered to the parent widget when handled: an
|
||||
// Escape that reaches a dialog's reject button would cancel the *window*, not
|
||||
// the plot's drag, because the widget has focus.
|
||||
case Qt::Key_Escape: mapped = input::kKeyEscape; break;
|
||||
case Qt::Key_Left: mapped = input::kKeyLeft; break;
|
||||
case Qt::Key_Right: mapped = input::kKeyRight; break;
|
||||
case Qt::Key_Up: mapped = input::kKeyUp; break;
|
||||
case Qt::Key_Down: mapped = input::kKeyDown; break;
|
||||
default:
|
||||
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
|
||||
return;
|
||||
@@ -307,4 +341,64 @@ void PlotWidget::keyPressEvent(QKeyEvent* event) {
|
||||
update();
|
||||
}
|
||||
|
||||
bool PlotWidget::event(QEvent* event) {
|
||||
if (event->type() == QEvent::DevicePixelRatioChange) {
|
||||
// The window moved to a display with another scale factor (or the scale of the
|
||||
// screen it is on changed). Everything the ratio decides -- the device
|
||||
// viewport the controller hit-tests against, and the `ui_scale` the next
|
||||
// frame's paint re-reads -- is refreshed here, before any event queued behind
|
||||
// this one maps a gesture through the old geometry. `paintGL()` would catch up
|
||||
// one frame later; a drag mid-gesture would pan by the wrong distance in
|
||||
// exactly that frame.
|
||||
refresh_viewport();
|
||||
}
|
||||
if (event->type() == QEvent::Gesture) {
|
||||
auto* gesture_event = static_cast<QGestureEvent*>(event);
|
||||
// The widget grabbed Qt::PinchGesture, so its pinch events arrive here; a
|
||||
// gesture this widget does not know is left for whoever else may want it.
|
||||
if (QPinchGesture* pinch =
|
||||
static_cast<QPinchGesture*>(gesture_event->gesture(Qt::PinchGesture));
|
||||
pinch != nullptr) {
|
||||
forward_pinch(pinch, gesture_event);
|
||||
}
|
||||
}
|
||||
return QOpenGLWidget::event(event);
|
||||
}
|
||||
|
||||
void PlotWidget::forward_pinch(QPinchGesture* gesture, QGestureEvent* event) {
|
||||
// The gesture reports cumulative values; `PinchTracker` folds them into the
|
||||
// incremental zoom and focal movement the controller's `GestureEvent` wants.
|
||||
// Positions are logical widget pixels here, exactly like a mouse event's, and
|
||||
// go through the same `devicePixelRatioF()` mapping `to_device()` applies.
|
||||
const QPointF logical_center = gesture->centerPoint();
|
||||
const f64 dpr = static_cast<f64>(devicePixelRatioF());
|
||||
const Vec2 logical{static_cast<f32>(logical_center.x()), static_cast<f32>(logical_center.y())};
|
||||
|
||||
switch (gesture->state()) {
|
||||
case Qt::GestureStarted:
|
||||
// The begin records the anchor only: a pinch that just started has zoomed
|
||||
// and moved nothing yet.
|
||||
pinch_.begin(static_cast<f32>(gesture->totalScaleFactor()), logical);
|
||||
break;
|
||||
case Qt::GestureUpdated:
|
||||
case Qt::GestureFinished: {
|
||||
// The finished state's last update is the gesture's final zoom: folded like
|
||||
// an update, so nothing the fingers did is dropped for ending.
|
||||
const input::GestureEvent g =
|
||||
pinch_.update(static_cast<f32>(gesture->totalScaleFactor()), logical, dpr);
|
||||
controller_.on_gesture(g);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Canceled: the fingers left the surface mid-gesture. No event -- whatever
|
||||
// zoom the updates delivered stands, and the next pinch begins fresh.
|
||||
break;
|
||||
}
|
||||
if (gesture->state() == Qt::GestureFinished || gesture->state() == Qt::GestureCanceled) {
|
||||
pinch_.end();
|
||||
}
|
||||
event->accept(gesture); // consumed: the plot zoomed/panned, no parent gets it too
|
||||
update();
|
||||
}
|
||||
|
||||
} // namespace plot::qt
|
||||
@@ -34,6 +34,9 @@ class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
class QPointF;
|
||||
class QWheelEvent;
|
||||
class QEvent;
|
||||
class QGestureEvent;
|
||||
class QPinchGesture;
|
||||
|
||||
namespace plot::qt {
|
||||
|
||||
@@ -106,10 +109,15 @@ namespace plot::qt {
|
||||
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
|
||||
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
|
||||
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
|
||||
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
|
||||
/// no vertical component is refused before it reaches the controller, because a
|
||||
/// zero-notch zoom changes no range but still runs the range setters, which clear
|
||||
/// auto scale: a horizontal scroll must not silently stop a live axis following.
|
||||
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event whose
|
||||
/// deltas both vanish -- no angle and no pixels -- is refused before it reaches the
|
||||
/// controller, because a zero-notch, zero-pan event changes nothing and must not
|
||||
/// clear auto scale; anything with a vertical *or* horizontal component is
|
||||
/// forwarded, and the controller decides what each does (zoom on `dy`, pan on
|
||||
/// `dx`). The widget is also a pinch source: `Qt::PinchGesture` is grabbed in the
|
||||
/// constructor and folded into `input::GestureEvent`s by `PinchTracker`
|
||||
/// (`qt_pointer.hpp`), so a two-finger zoom-about-focal-point and a two-finger pan
|
||||
/// reach the controller like the equivalent wheel + drag pair would.
|
||||
///
|
||||
/// ## Threading
|
||||
///
|
||||
@@ -170,6 +178,12 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
void initializeGL() override;
|
||||
void resizeGL(int w, int h) override;
|
||||
void paintGL() override;
|
||||
/// The one virtual that sees what no dedicated handler does: the device pixel
|
||||
/// ratio change (`QEvent::DevicePixelRatioChange`) and the grabbed pinch
|
||||
/// (`QEvent::Gesture`). Both refresh or feed the controller before the next
|
||||
/// paint, so a gesture arriving between the change and the frame maps against
|
||||
/// the geometry the ratio now implies.
|
||||
bool event(QEvent* event) override;
|
||||
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
@@ -196,6 +210,10 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
/// Sets the plot's viewport to the current device rect (through the controller,
|
||||
/// which refreshes the layout with it).
|
||||
void refresh_viewport();
|
||||
/// Folds one pinch update into a `GestureEvent` through `PinchTracker` and hands
|
||||
/// it to the controller. `event` is accepted for the pinch gesture, so Qt does
|
||||
/// not offer it to parents too.
|
||||
void forward_pinch(QPinchGesture* gesture, QGestureEvent* event);
|
||||
|
||||
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
|
||||
input::Controller controller_;
|
||||
@@ -207,6 +225,9 @@ class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
/// reports no delta of its own, and the ratio can change between two events
|
||||
/// (see `PointerAnchor` for why the anchor must not be device pixels).
|
||||
PointerAnchor anchor_;
|
||||
/// The pinch gesture's folding state (see `PinchTracker`): one per widget, alive
|
||||
/// between `Qt::GestureStarted` and `Finished`/`Canceled`.
|
||||
PinchTracker pinch_;
|
||||
|
||||
u64 frames_ = 0;
|
||||
f32 last_frame_ms_ = 0.0f;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/input/events.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
|
||||
namespace plot::qt {
|
||||
@@ -49,4 +50,69 @@ struct PointerAnchor {
|
||||
}
|
||||
};
|
||||
|
||||
/// 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
|
||||
Reference in new issue
Block a user