214 lines
12 KiB
Markdown
214 lines
12 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()` |
|
||
|
||
**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`, 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):
|
||
|
||
```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`. 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:
|
||
|
||
```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, `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`.
|