docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing
This commit is contained in:
1 parent
d03f9ce51a
commit
b7d0f901cc
7 files changed
+1082
No files matched your search
@@ -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`.
|
||||
Reference in new issue
Block a user