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
+98
-13
@@ -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).
|
||||
|
||||
|
||||
Reference in new issue
Block a user