# 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 = ; 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()` | **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`/`F` re-fits, the four arrows pan (10% of the span per press), and Escape cancels the active drag. Each host translates its native key codes to `input::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_s` is 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=` (stop the loop after that long) and `PLOT_DEMO_SCREENSHOT=` (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 `, 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`, 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-const `layout()` recomputes, and a burst of pointer moves would re-lay every row out per move. The run loop calls `refresh_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 calls `refresh_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 `, 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). 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 `, 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`, 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 ``, because Qt reaches `` 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 `, 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 (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`.