Files
plotter/adapters/qt6/qt_plot_widget.cpp
T
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00

311 lines
13 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <qt6/qt_plot_widget.hpp>
#include <qt6/qt_pointer.hpp>
#include <plot/gl/loader.hpp>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QOpenGLContext>
#include <QPointF>
#include <QWheelEvent>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
namespace plot::qt {
namespace {
/// The entry points `QOpenGLContext::getProcAddress()` could not provide, as
/// passed to `plot::gl::load()`. `load()` may still resolve them through its
/// dlsym fallback, but only the context-aware resolver is guaranteed to return
/// pointers valid for the current context, so a fallback is worth reporting.
/// Process-global and written only from the context's owning thread, which is the
/// only thread `initializeGL()` runs on.
constexpr u32 kMaxUnresolved = 16;
const char* g_unresolved[kMaxUnresolved] = {};
u32 g_unresolved_count = 0;
/// Qt's own resolver for the current context, as `plot::gl::ProcLoader`.
///
/// `getProcAddress()` returns Qt's `QFunctionPointer` (`void (*)()`), the loader
/// wants `void*`: a function pointer respelled as an object pointer. POSIX
/// guarantees the two have the same representation, and the bit copy states that
/// where a `reinterpret_cast` is merely conditionally supported -- the same
/// reasoning the GLFW adapter uses for `glfwGetProcAddress`.
void* resolve_via_qt(const char* name) noexcept {
QOpenGLContext* context = QOpenGLContext::currentContext();
if (context == nullptr) return nullptr;
const QFunctionPointer proc = context->getProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(proc), "function and object pointers must match");
std::memcpy(&out, &proc, sizeof(out));
if (out == nullptr && g_unresolved_count < kMaxUnresolved) {
g_unresolved[g_unresolved_count++] = name;
}
return out;
}
/// Qt's modifier flags as the controller's translatable mask. Unlike GLFW's
/// GLFW_MOD_* these are Qt's own bits, so this is a genuine translation and not
/// the reinterpretation the GLFW adapter gets away with.
u32 mods_from(Qt::KeyboardModifiers mods) noexcept {
u32 out = 0;
if (mods.testFlag(Qt::ShiftModifier)) out |= static_cast<u32>(input::Mod::Shift);
if (mods.testFlag(Qt::ControlModifier)) out |= static_cast<u32>(input::Mod::Ctrl);
if (mods.testFlag(Qt::AltModifier)) out |= static_cast<u32>(input::Mod::Alt);
if (mods.testFlag(Qt::MetaModifier)) out |= static_cast<u32>(input::Mod::Super);
return out;
}
/// The controller has no gesture for the auxiliary buttons, and Qt always names
/// one of the three on a press or a release -- the only events whose button is
/// read. A move's `Qt::NoButton` maps to Left, which nothing looks at.
input::Button button_from(Qt::MouseButton button) noexcept {
switch (button) {
case Qt::MiddleButton: return input::Button::Middle;
case Qt::RightButton: return input::Button::Right;
default: return input::Button::Left;
}
}
/// True while any button the controller has a gesture for is held.
bool any_plot_button(Qt::MouseButtons buttons) noexcept {
return buttons.testFlag(Qt::LeftButton) || buttons.testFlag(Qt::MiddleButton) ||
buttons.testFlag(Qt::RightButton);
}
f64 now_s() noexcept {
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
} // namespace
PlotWidget::PlotWidget(Plot& plot, QWidget* parent)
: QOpenGLWidget(parent), plot_(&plot), controller_(plot) {
// Hover has to reach the plot: the controller's cursor hit test reads the last
// pointer position even while no button is down.
setMouseTracking(true);
// The controller's keys (+, - and f) are input to the plot, and a widget without
// a focus policy never receives a key event.
setFocusPolicy(Qt::StrongFocus);
}
PlotWidget::~PlotWidget() {
// The renderer's RAII handles call glDelete*, which needs the context current
// and alive. This body runs before ~QOpenGLWidget tears the context down, which
// is what makes makeCurrent() valid here -- and `renderer_` is engaged only if
// initializeGL() ran, so there is a context to make current.
if (renderer_) {
makeCurrent();
renderer_.reset();
doneCurrent();
}
}
void PlotWidget::initializeGL() {
initializeOpenGLFunctions();
// A second call means Qt recreated the context (the widget moved to another
// screen or window): the names the renderer holds belonged to the old context,
// and nothing here may outlive it. This runs before anything of ours is created
// on the new context, so the deletes it issues can only name objects that are
// not there.
renderer_.reset();
g_unresolved_count = 0;
const bool loaded = plot::gl::load(&resolve_via_qt);
// Printed before the failure branch, not after it: the names collected here are
// precisely what explains a failure -- which 3.3 entry point this context could
// not provide -- so a load that failed must report them too, or the one failure
// this widget can have says nothing but "missing a required entry point".
if (g_unresolved_count > 0) {
std::fprintf(stderr,
"[plot::qt6] QOpenGLContext::getProcAddress() did not resolve %u required"
" entry point(s); plot::gl::load() %s for:",
g_unresolved_count, loaded ? "used its dlsym fallback" : "failed");
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
std::fprintf(stderr, "\n");
}
if (!loaded) {
init_status_ = Status{ErrorCode::Unsupported,
"plot::gl::load() failed: the current context is missing a required"
" OpenGL 3.3 entry point"};
std::fprintf(stderr, "[plot::qt6] %s\n", init_status_.message);
return;
}
renderer_.emplace();
Status status;
if (!renderer_->init(&status)) {
init_status_ = status.code != ErrorCode::Ok
? status
: Status{ErrorCode::GLFailure, "Renderer::init() failed"};
// Leave the renderer engaged: its destructor is what releases whatever the
// partial init did build, and it runs with the context current.
std::fprintf(stderr, "[plot::qt6] Renderer::init(): %s: %s\n", to_string(init_status_.code),
init_status_.message);
return;
}
init_status_ = Status{};
refresh_viewport();
}
void PlotWidget::resizeGL(int w, int h) {
(void)w;
(void)h;
// Nothing about the size is cached: `paintGL()` derives the viewport from the
// live geometry every frame, so a frame can never be drawn at a stale size.
// Refreshing it here as well means a gesture that arrives between the resize and
// the next paint maps against the new geometry rather than the old one.
refresh_viewport();
}
void PlotWidget::paintGL() {
if (!renderer_ || !renderer_->ready()) return;
const Rect viewport = device_viewport();
if (viewport.w < 1.0f || viewport.h < 1.0f) return; // nothing to draw into
// The controller owns no viewport: this hands it to the plot (which re-lays the
// chrome out with it) and refreshes the layout, so every frame is laid out for
// the geometry of *this* frame. A resize changes the layout; it never scales a
// stale one.
controller_.set_viewport(viewport);
const f64 started = now_s();
const f64 dt = last_paint_s_ >= 0.0 ? started - last_paint_s_ : 0.0;
last_paint_s_ = started;
if (frame_) frame_(*plot_, dt);
// The same factor `device_viewport()` scales the widget by: it states the display
// for the renderer too, so the logical style metrics are drawn at this ratio and
// a 12 px label keeps its physical size on a 2x screen. Re-read every frame, like
// the viewport, so a window moved to another display is drawn at *its* scale.
renderer_->style().ui_scale = static_cast<f32>(devicePixelRatioF());
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((now_s() - started) * 1.0e3);
last_frame_ms_ = frame_ms;
// Seeded by the first frame rather than started at zero, so the mean is the mean
// of the frames that ran from the first one.
mean_frame_ms_ = frames_ == 0 ? frame_ms : mean_frame_ms_ + 0.1f * (frame_ms - mean_frame_ms_);
last_viewport_ = viewport;
++frames_;
}
Rect PlotWidget::device_viewport() const noexcept {
// Logical -> device pixels, rounded: at 1.25 a 1023-pixel-wide widget is
// 1278.75 device pixels, which must cover 1279 framebuffer columns or the last
// one is left outside the plot (and outside the renderer's clear).
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const long w = std::lround(static_cast<f64>(width()) * dpr);
const long h = std::lround(static_cast<f64>(height()) * dpr);
return Rect{0.0f, 0.0f, static_cast<f32>(w), static_cast<f32>(h)};
}
Vec2 PlotWidget::to_device(const QPointF& p) const noexcept {
// The same factor `device_viewport()` scales the widget by, applied to a
// position and to the deltas derived from two of them. One convention on both
// sides is what makes a gesture land on the data under the cursor.
const f64 dpr = static_cast<f64>(devicePixelRatioF());
return Vec2{static_cast<f32>(p.x() * dpr), static_cast<f32>(p.y() * dpr)};
}
void PlotWidget::refresh_viewport() { controller_.set_viewport(device_viewport()); }
void PlotWidget::forward_pointer(QMouseEvent* event, bool pressed, bool released) {
// The anchor stays logical (see PointerAnchor): a move that arrives after the
// window moved to a display with another scale factor must not measure the gap
// between a position taken at the old DPR and one taken at the new one. The
// delta is the logical difference scaled by the ratio in effect now, so the same
// gesture maps to the same device distance at either scale.
const QPointF position = event->position();
const f64 dpr = static_cast<f64>(devicePixelRatioF());
const Vec2 logical{static_cast<f32>(position.x()), static_cast<f32>(position.y())};
const Vec2 p = to_device(position);
const Vec2 delta = anchor_.device_delta(logical, dpr);
input::PointerEvent e;
e.x = p.x;
e.y = p.y;
e.dx = delta.x;
e.dy = delta.y;
// A press is also a pointer sample: the next move's delta starts here, or the
// first motion of a drag would include the jump to the click.
anchor_.set(logical);
e.button = button_from(event->button());
e.mods = mods_from(event->modifiers());
e.down = any_plot_button(event->buttons());
e.pressed = pressed;
e.released = released;
controller_.on_pointer(e);
event->accept();
update();
}
void PlotWidget::mousePressEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::mouseMoveEvent(QMouseEvent* event) { forward_pointer(event, false, false); }
void PlotWidget::mouseReleaseEvent(QMouseEvent* event) { forward_pointer(event, false, true); }
void PlotWidget::mouseDoubleClickEvent(QMouseEvent* event) { forward_pointer(event, true, false); }
void PlotWidget::wheelEvent(QWheelEvent* event) {
// Qt reports eighths of a degree, 120 to a notch; the controller takes notches,
// positive = zoom in, which is Qt's positive angleDelta().y() as well (the wheel
// pushed away from the user). Passing the raw delta would zoom 120x too far.
const f32 notches = static_cast<f32>(event->angleDelta().y()) / 120.0f;
if (notches == 0.0f) {
// A horizontal-only wheel is not a zoom, and it must not reach the controller:
// a zero-notch zoom changes no range but still runs the range setters, which
// clear auto scale -- so a stray horizontal scroll would silently stop a live
// axis from following its stream. Ignored, so an enclosing scroll area may
// still act on it.
event->ignore();
return;
}
const Vec2 p = to_device(event->position());
input::WheelEvent e;
e.x = p.x;
e.y = p.y;
e.dx = static_cast<f32>(event->angleDelta().x()) / 120.0f;
e.dy = notches;
e.mods = mods_from(event->modifiers());
controller_.on_wheel(e);
event->accept();
update();
}
void PlotWidget::keyPressEvent(QKeyEvent* event) {
int mapped = 0;
switch (event->key()) {
// The controller zooms on '+', which on most layouts is shift+'='; '=' is
// mapped to it as well, so both spellings zoom in -- the pair the GLFW adapter
// maps. Shift+'-' arrives as its own key, and zooms out like '-'.
case Qt::Key_Plus:
case Qt::Key_Equal: mapped = input::kKeyZoomIn; break;
case Qt::Key_Minus:
case Qt::Key_Underscore: mapped = input::kKeyZoomOut; break;
case Qt::Key_F: mapped = input::kKeyFit; break;
default:
QOpenGLWidget::keyPressEvent(event); // not a plot key: nobody else wants it
return;
}
input::KeyEvent e;
e.key = mapped;
e.mods = mods_from(event->modifiers());
// Qt reports a held key as repeated presses and sends no release until the key
// is physically released, so the repeat is what makes a held key keep acting.
e.down = true;
controller_.on_key(e);
event->accept();
update();
}
} // namespace plot::qt