Files
plotter/docs/embedding.md
T

12 KiB
Raw Blame History

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()

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:

  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, 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.

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 one hover over every row (sync_figure_crosshair), since only the host knows which row the pointer is in.

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).

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. 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.

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, 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 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.