feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
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

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:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+105 -11
View File
@@ -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