docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing

This commit is contained in:
Martino Ferrari committed 2026-09-28 21:50:19 +02:00
1 parent d03f9ce51a
commit b7d0f901cc
7 files changed
+1082

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
# 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`.