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.
299 lines
18 KiB
Markdown
299 lines
18 KiB
Markdown
# Embedding into a host
|
||
|
||
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
|
||
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
|
||
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
|
||
|
||
```cpp
|
||
renderer.style().ui_scale = <device pixels per logical unit>;
|
||
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
|
||
renderer.draw(plot); // or renderer.draw(figure)
|
||
renderer.end_frame(); // restores the GL state it moved
|
||
```
|
||
|
||
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
|
||
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
|
||
pointer or a button.
|
||
|
||
Below are the four shipped adapters. Each is a separate opt-in target
|
||
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
|
||
under `adapters/`.
|
||
|
||
## Shared conventions
|
||
|
||
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
|
||
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
|
||
*logical* units (window points, widget pixels) and converts before forwarding, so a
|
||
gesture lands on the pixels the plot was laid out in.
|
||
|
||
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
|
||
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
|
||
and the renderer converts each metric through `Style::device()` exactly once. The host
|
||
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
|
||
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
|
||
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
|
||
scale:
|
||
|
||
| host | value written to `ui_scale` | source |
|
||
|---|---|---|
|
||
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
|
||
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
|
||
| 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
|
||
and no screen capture. The invocations it uses (each demo, from the repository root, all
|
||
five exit `0`):
|
||
|
||
```sh
|
||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
|
||
xvfb-run -a ./build/release/examples/plot_glfw_demo
|
||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
|
||
xvfb-run -a ./build/release/examples/plot_multiplot_demo
|
||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
|
||
xvfb-run -a ./build/release/examples/plot_imgui_demo
|
||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
|
||
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||
./build/release/examples/plot_qt_demo
|
||
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
|
||
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
|
||
./build/release/examples/plot_gtk_demo
|
||
```
|
||
|
||
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
|
||
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
|
||
that self-check reports a false failure.
|
||
|
||
## GLFW — `plot::glfw`
|
||
|
||
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
|
||
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
|
||
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
|
||
|
||
```cpp
|
||
plot::glfw::Host host;
|
||
plot::Plot& p = host.plot(); // populate axes/traces here
|
||
plot::Status st;
|
||
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
|
||
```
|
||
|
||
What `run()` does, in order:
|
||
|
||
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
|
||
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
|
||
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
|
||
pointer respelled as `void*` by bit copy).
|
||
3. `Renderer::init()`.
|
||
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
|
||
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
|
||
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
|
||
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
|
||
|
||
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`, 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 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.
|
||
|
||
## Dear ImGui — `plot::imgui`
|
||
|
||
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
|
||
by `FetchContent`, so `find_package(plot)` does not export this target.
|
||
|
||
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
|
||
`ImGui::Render()`, from inside a window. It:
|
||
|
||
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
|
||
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
|
||
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
|
||
does not paint over what is beside it;
|
||
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
|
||
the hover gate (false while another widget is being dragged) and the item that
|
||
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
|
||
over window scrolling;
|
||
4. maps the child's point rectangle to framebuffer pixels through
|
||
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
|
||
(`begin_frame`/`draw`/`end_frame`) into it.
|
||
|
||
Because ImGui reports button *levels* and the controller wants *edges*, the widget
|
||
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). 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
|
||
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
|
||
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
|
||
|
||
## Qt6 — `plot::qt6`
|
||
|
||
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
|
||
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
|
||
of its own.
|
||
|
||
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
|
||
application default `QSurfaceFormat` and cannot renegotiate):
|
||
|
||
```cpp
|
||
QSurfaceFormat fmt;
|
||
fmt.setVersion(3, 3);
|
||
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||
fmt.setDepthBufferSize(0);
|
||
fmt.setStencilBufferSize(0);
|
||
QSurfaceFormat::setDefaultFormat(fmt);
|
||
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
|
||
```
|
||
|
||
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
|
||
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
|
||
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
|
||
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
|
||
viewport on the controller, runs the frame callback, sets `ui_scale` to
|
||
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
|
||
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
|
||
|
||
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
|
||
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
|
||
`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`). 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
|
||
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
|
||
enumerator after a Qt GL header.
|
||
|
||
## GTK4 — `plot::gtk4`
|
||
|
||
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
|
||
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
|
||
factory, not a subclass:
|
||
|
||
```cpp
|
||
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
|
||
GtkWidget* widget = area->widget(); // put it in your container
|
||
```
|
||
|
||
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
|
||
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
|
||
|
||
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
|
||
entry points and initialises the renderer (recording the outcome in `init_status()`);
|
||
`::unrealize` releases the renderer's GL objects while the context can still be made
|
||
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
|
||
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
|
||
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 (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 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).
|
||
|
||
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
|
||
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
|