#include #include #include #include #include #include #include #include #include #include #include #include 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(input::Mod::Shift); if (mods.testFlag(Qt::ControlModifier)) out |= static_cast(input::Mod::Ctrl); if (mods.testFlag(Qt::AltModifier)) out |= static_cast(input::Mod::Alt); if (mods.testFlag(Qt::MetaModifier)) out |= static_cast(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(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); renderer_->begin_frame(static_cast(viewport.w), static_cast(viewport.h), viewport); renderer_->draw(*plot_); renderer_->end_frame(); const f32 frame_ms = static_cast((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(devicePixelRatioF()); const long w = std::lround(static_cast(width()) * dpr); const long h = std::lround(static_cast(height()) * dpr); return Rect{0.0f, 0.0f, static_cast(w), static_cast(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(devicePixelRatioF()); return Vec2{static_cast(p.x() * dpr), static_cast(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(devicePixelRatioF()); const Vec2 logical{static_cast(position.x()), static_cast(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(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(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