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

+98 -13
View File
@@ -41,6 +41,48 @@ scale:
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
**Keys, key repeat, timestamps and touch parity.** The controller's keys and gestures
arrive through the adapters translated from each toolkit's own events, and four
conventions hold across all four hosts:
* **The key set is the same everywhere**: `+`/`-` zoom (with the `=`/`_` spellings a
shifted layout produces), `f`/`F` re-fits, the four arrows pan (10% of the span per
press), and Escape cancels the active drag. Each host translates its native key codes
to `input::kKey*` before calling the controller.
* **A held key repeats in every adapter.** GLFW, Qt and GTK deliver the OS auto-repeat
as extra press events and forward each one. ImGui edge-detects a press exactly once,
so its widget asks `IsKeyPressed(..., repeat = true)` for every key it maps and
ImGui's own repeat timer supplies what the OS would have.
* **`PointerEvent::time_s` is the host's monotonic clock, in seconds.** Only
differences are read: the double-click (two presses of the same button within 0.35 s
and 4 px re-fits every axis) compares two stamps, never an epoch. A host that leaves
the field 0 never produces a double-click, by contract.
| host | key repeat | `time_s` source | touch / pinch |
|---|---|---|---|
| GLFW | the OS auto-repeat, forwarded as repeated presses | `glfwGetTime()` | none — GLFW has no touch input |
| ImGui | `IsKeyPressed(..., repeat = true)` on every mapped key | `ImGui::GetTime()` | none — no touch input to forward |
| Qt6 | Qt repeats a held key as press events | `QInputEvent::timestamp()` / 1000 | `Qt::PinchGesture` → `GestureEvent`, folded by `PinchTracker` (`qt_pointer.hpp`) |
| GTK4 | GTK repeats a held key as press events | the GDK event's time / 1000 | `GtkGestureZoom` → `GestureEvent`, folded by `ZoomAnchor` (`gtk_input.hpp`) |
The two pinch folds are the same fold in two namespaces, and both live in
toolkit-free headers precisely so the headless tests can drive them: a toolkit
reports a pinch as **cumulative** values (total scale since the gesture began, the
focal point now), while `GestureEvent` wants the increments (this frame's zoom factor
about the focal point, 1.0 = no change, and the focal point's movement as `dx`/`dy`).
The folds also keep their focal anchor in *logical* pixels, like every other adapter
anchor, so a window dragged onto a display with another scale factor mid-gesture
cannot read the ratio change as a phantom pan.
**DPR / display changes.** The scale each host states to the renderer is re-read
whenever it can change, not latched: GLFW re-derives it in `refresh_sizes()` (every
frame, and from the resize callback), ImGui every frame from
`io.DisplayFramebufferScale`, Qt every `paintGL()` **and** on
`QEvent::DevicePixelRatioChange` — which also refreshes the controller's viewport
before any event queued behind the change maps a gesture through the old geometry —
and GTK in `::resize` through `DeviceMapping`. An input coordinate is always scaled by
the ratio in force at that event, never by one cached at startup.
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
@@ -93,16 +135,39 @@ What `run()` does, in order:
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
reports no delta); `down` comes from `glfwGetMouseButton`, the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask, and
`time_s` is `glfwGetTime()`. Wheel notches are forwarded as `WheelEvent` unchanged
(zero deltas included — the controller owns that case), and keys as `KeyEvent`: the
arrows pan, Escape cancels the active drag, and neither closes the window (the window
manager's close button and Alt+F4 are the way to quit).
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
in (`controller_at(x, y)`), and draw it with `Renderer::draw(Figure&)`. The host also spreads
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in.
Routing has two rules worth knowing from the outside:
* **One gesture at a time (button-aware capture).** A press captures the row it landed
in *and the button that pressed*, and every motion and the release of *that button* go
to the captured row until it comes up — so a drag that crosses into a neighbouring row
still ends where it began. A second button pressed meanwhile (a chord) neither steals
the capture to another row nor ends it; only the owning button's release ends it. What
the second button's events do inside the controller is the controller's business; the
host guarantees they all arrive at the row the first press claimed.
* **Row rects are cached, refreshed once per frame.** The events never call
`Figure::layout()` — a non-const `layout()` recomputes, and a burst of pointer moves
would re-lay every row out per move. The run loop calls `refresh_row_rects()` once per
frame (right after the figure's viewport is set), `set_figure()` fills the cache
immediately so a scripted run routes against the figure it was just handed, and every
handler reads the cache. The consequence across a resize: GLFW delivers the resize and
any events queued behind it in one batch, so events before the resize see the rows the
previous frame drew — at most one frame stale — and the loop's next refresh brings the
cache onto the new geometry. A driver that injects events without running the loop
calls `refresh_row_rects()` itself; it is the scripted equivalent of the frame.
`run()` leaves the window open on success so a producer thread can be joined before the GL
resources go away; `close()` (or the destructor) releases the renderer, context and window
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
@@ -131,7 +196,11 @@ Because ImGui reports button *levels* and the controller wants *edges*, the widg
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
pressed over the child keeps being delivered until every button is up, so a drag that
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
treated as no position update (no movement, buttons still flow).
treated as no position update (no movement, buttons still flow). Every pointer event is
stamped with `ImGui::GetTime()`, which is what makes the controller's double-click
(re-fit) exist on this host; and every key the widget maps is asked with
`IsKeyPressed(..., repeat = true)`, because ImGui does not auto-repeat on its own —
the shared "a held key repeats" policy is opt-in here (see the conventions above).
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
@@ -168,12 +237,22 @@ viewport on the controller, runs the frame callback, sets `ui_scale` to
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`, each stamped with
`QInputEvent::timestamp()` in seconds. Every position is multiplied by
`devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
pixels, so a drag crossing onto a display with another scale factor pans by the distance
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
vertical component is refused before it reaches the controller.
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). The wheel is
converted from eighths of a degree, 120 to a notch; when a device reports
`pixelDelta()` instead (and the angle deltas are both zero), 40 scroll pixels are taken
to be one notch — a calibration, not a conversion, the same role the GTK adapter's
`kSurfacePixelsPerNotch` plays. A wheel event with neither a vertical nor a horizontal
component is refused before it reaches the controller; anything with one is forwarded,
and the controller pans x by the horizontal component. The widget also grabs
`Qt::PinchGesture`: each pinch update is folded by `PinchTracker` (`qt_pointer.hpp`)
into an incremental `GestureEvent` — this frame's zoom about the focal point, the focal
point's movement as the pan — and a `QEvent::DevicePixelRatioChange` refreshes the
controller's viewport (and, one frame later at the latest, the `ui_scale`) before any
event queued behind it.
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
@@ -202,10 +281,16 @@ and the device size GTK reports, and refreshes the viewport; `::render` sets the
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
`GtkGestureClick` supplies the press/release edges (stamped with the GDK event's time,
in seconds, like every pointer event this adapter forwards), `GtkGestureZoom` supplies
the two-finger pinch — each `scale-changed` folded by `ZoomAnchor` (`gtk_input.hpp`)
into an incremental `GestureEvent`: this frame's zoom about the fingers' bounding-box
centre, the centre's movement as the pan — `GtkEventControllerMotion` supplies
hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and
the keys (`+`/`-`/`f`, the arrows, Escape — the full shared key set, mapped in
`gtk_input.hpp`'s `key_from()` where the headless tests drive it). GTK emits
`drag-begin` before the click's `pressed`, so the adapter opens
its gesture from the first press and closes it from the first release. The area queues a
render after every input event that can change the picture (not on hover).