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.
18 KiB
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
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). 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/Fre-fits, the four arrows pan (10% of the span per press), and Escape cancels the active drag. Each host translates its native key codes toinput::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_sis 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):
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.
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:
glfwInit(), then window hints for OpenGL 3.3 core (plus forward compatibility) andglfwCreateWindow(),glfwMakeContextCurrent(),glfwSwapInterval(vsync).plot::gl::load(&resolve_gl), whereresolve_glwrapsglfwGetProcAddress(a function pointer respelled asvoid*by bit copy).Renderer::init().- Per frame:
glfwPollEvents()→refresh_sizes()→controller.set_viewport(framebuffer rect)→ the host'sframecallback →begin_frame(fb_width, fb_height, viewport)→draw(plot)(ordraw(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-constlayout()recomputes, and a burst of pointer moves would re-lay every row out per move. The run loop callsrefresh_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 callsrefresh_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:
- opens a
BeginChildregion of the requested size (no scrollbar) and forcesImGuiCol_ChildBgtransparent, so the widget never paints over its own plot; - 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;
- covers the visible region with an
InvisibleButtonand readsIsItemHovered(); this is the hover gate (false while another widget is being dragged) and the item thatSetItemKeyOwner(ImGuiKey_MouseWheelY)claims the wheel with, so the plot's zoom wins over window scrolling; - 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):
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:
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.