feat(qt6): QOpenGLWidget-based plot widget and demo
This commit is contained in:
1 parent
dc0a977c86
commit
1ee2dd9c3d
7 files changed
+1113
-2
No files matched your search
@@ -49,6 +49,10 @@ if(PLOT_BUILD_IMGUI)
|
|||||||
add_subdirectory(adapters/imgui)
|
add_subdirectory(adapters/imgui)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(PLOT_BUILD_QT6)
|
||||||
|
add_subdirectory(adapters/qt6)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(PLOT_BUILD_EXAMPLES)
|
if(PLOT_BUILD_EXAMPLES)
|
||||||
add_subdirectory(examples)
|
add_subdirectory(examples)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
+4
-2
@@ -15,7 +15,8 @@
|
|||||||
"CMAKE_BUILD_TYPE": "Debug",
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
"PLOT_BUILD_GLFW": true,
|
"PLOT_BUILD_GLFW": true,
|
||||||
"PLOT_BUILD_IMGUI": true,
|
"PLOT_BUILD_IMGUI": true,
|
||||||
"PLOT_BUILD_EXAMPLES": true
|
"PLOT_BUILD_EXAMPLES": true,
|
||||||
|
"PLOT_BUILD_QT6": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"PLOT_BUILD_GLFW": true,
|
"PLOT_BUILD_GLFW": true,
|
||||||
"PLOT_BUILD_IMGUI": true,
|
"PLOT_BUILD_IMGUI": true,
|
||||||
"PLOT_BUILD_EXAMPLES": true
|
"PLOT_BUILD_EXAMPLES": true,
|
||||||
|
"PLOT_BUILD_QT6": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# The Qt6 adapter: a QOpenGLWidget that renders a plot into its own framebuffer
|
||||||
|
# and drives it from Qt's events. It is the third host, and the first whose
|
||||||
|
# toolkit already owns a GL widget -- so the interesting parts are the surface
|
||||||
|
# format the application must have set before the widget exists (documented on
|
||||||
|
# the class) and the device-pixel-ratio mapping between Qt's logical coordinates
|
||||||
|
# and the framebuffer pixels the library speaks.
|
||||||
|
#
|
||||||
|
# Unlike the GLFW and ImGui adapters this one is not fetched: Qt is a system SDK,
|
||||||
|
# and a project that enables this target is asking for the installed one.
|
||||||
|
find_package(Qt6 QUIET COMPONENTS Core Gui Widgets OpenGLWidgets)
|
||||||
|
|
||||||
|
if(NOT Qt6_FOUND)
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"plot: PLOT_BUILD_QT6 is ON but Qt6 was not found (need the Core, Gui, Widgets"
|
||||||
|
" and OpenGLWidgets components). Install Qt6 -- on Arch, qt6-base -- or"
|
||||||
|
" configure with -DPLOT_BUILD_QT6=OFF, or point CMAKE_PREFIX_PATH at the Qt6"
|
||||||
|
" installation.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(plot_qt6 STATIC qt_plot_widget.cpp qt_plot_widget.hpp)
|
||||||
|
add_library(plot::qt6 ALIAS plot_qt6)
|
||||||
|
# The public include root is `adapters/`, so the header is reached as
|
||||||
|
# <qt6/qt_plot_widget.hpp> -- the toolkit's name first, like every other adapter's.
|
||||||
|
target_include_directories(plot_qt6 PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..")
|
||||||
|
# Qt is PUBLIC, unlike GLFW and ImGui: the widget's header derives from
|
||||||
|
# QOpenGLWidget, so a consumer needs Qt's headers and libraries to use it at all.
|
||||||
|
target_link_libraries(plot_qt6 PUBLIC
|
||||||
|
plot::core
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Widgets
|
||||||
|
Qt6::OpenGLWidgets)
|
||||||
|
# The widget declares Q_OBJECT, so its header has to go through moc. The property
|
||||||
|
# is set on this target rather than globally: no other target in the project has a
|
||||||
|
# Qt class in it, and AUTOMOC scans every source of a target it is set on.
|
||||||
|
set_target_properties(plot_qt6 PROPERTIES AUTOMOC ON)
|
||||||
|
plot_apply_warnings(plot_qt6)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
#include <qt6/qt_plot_widget.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;
|
||||||
|
if (!plot::gl::load(&resolve_via_qt)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (g_unresolved_count > 0) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[plot::qt6] QOpenGLContext::getProcAddress() did not resolve %u required"
|
||||||
|
" entry point(s); plot::gl::load() used its dlsym fallback for:",
|
||||||
|
g_unresolved_count);
|
||||||
|
for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]);
|
||||||
|
std::fprintf(stderr, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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) {
|
||||||
|
const Vec2 p = to_device(event->position());
|
||||||
|
input::PointerEvent e;
|
||||||
|
e.x = p.x;
|
||||||
|
e.y = p.y;
|
||||||
|
if (have_pointer_) {
|
||||||
|
e.dx = p.x - last_x_;
|
||||||
|
e.dy = p.y - last_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.
|
||||||
|
last_x_ = p.x;
|
||||||
|
last_y_ = p.y;
|
||||||
|
have_pointer_ = true;
|
||||||
|
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
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
|
||||||
|
// <QtGui/qopengl.h>, which on a desktop platform includes the system <GL/gl.h>;
|
||||||
|
// that header defines GL_TRUE, GL_COLOR_BUFFER_BIT, GL_TEXTURE_2D and the rest as
|
||||||
|
// preprocessor macros, and those are exactly the names plot/gl/api.hpp declares as
|
||||||
|
// enumerators -- a macro rewrite turns each definition into nonsense. Parsing
|
||||||
|
// api.hpp before the macros exist is what lets the two coexist (the GLFW adapter
|
||||||
|
// sidesteps the same clash with GLFW_INCLUDE_NONE, which Qt offers no equivalent
|
||||||
|
// of). A translation unit that uses this widget must keep the same order for
|
||||||
|
// anything it includes itself, and must not name a `plot::gl::GL_*` enumerator
|
||||||
|
// after a Qt header that reaches GL has been included: the macro rewrites that
|
||||||
|
// token wherever it appears, namespace and all.
|
||||||
|
#include <plot/core/rect.hpp>
|
||||||
|
#include <plot/core/status.hpp>
|
||||||
|
#include <plot/core/types.hpp>
|
||||||
|
#include <plot/input/controller.hpp>
|
||||||
|
#include <plot/math/vec2.hpp>
|
||||||
|
#include <plot/plot/plot.hpp>
|
||||||
|
#include <plot/render/renderer.hpp>
|
||||||
|
|
||||||
|
#include <QOpenGLFunctions>
|
||||||
|
#include <QOpenGLWidget>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <optional>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
class QKeyEvent;
|
||||||
|
class QMouseEvent;
|
||||||
|
class QPointF;
|
||||||
|
class QWheelEvent;
|
||||||
|
|
||||||
|
namespace plot::qt {
|
||||||
|
|
||||||
|
/// Embeds a `Plot` in a Qt widget: a `QOpenGLWidget` whose framebuffer the plot
|
||||||
|
/// is rendered into, with Qt's own input events translated into `plot::input`
|
||||||
|
/// ones.
|
||||||
|
///
|
||||||
|
/// `QOpenGLWidget` renders into an FBO of its own, which it binds before calling
|
||||||
|
/// `paintGL()` -- exactly the "currently bound framebuffer" `Renderer` documents
|
||||||
|
/// it draws into. The widget therefore never creates a target of its own, and the
|
||||||
|
/// renderer's scissor-cleared viewport is the widget's whole rectangle. Nothing is
|
||||||
|
/// rendered to a texture or through a second pass: the plot is what Qt composes,
|
||||||
|
/// and the widget's other content (Qt draws none; a `QOpenGLWidget` is opaque)
|
||||||
|
/// would be drawn around it.
|
||||||
|
///
|
||||||
|
/// ## The surface format is a precondition, not a setting
|
||||||
|
///
|
||||||
|
/// This widget cannot ask for a context version: Qt hands a widget the
|
||||||
|
/// *application default* `QSurfaceFormat` at the moment its context is created,
|
||||||
|
/// and a context cannot be renegotiated afterwards. An embedding application must
|
||||||
|
/// therefore have done this before the widget exists:
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
///
|
||||||
|
/// The last line matters as much as the rest: an ES or ANGLE backend has no 3.3
|
||||||
|
/// core profile to give, and this library's loader resolves desktop entry points.
|
||||||
|
/// `initializeGL()` reports the failure through `init_status()` rather than
|
||||||
|
/// asserting, but it cannot repair a format that was already decided.
|
||||||
|
///
|
||||||
|
/// ## The coordinate convention: device pixels, both sides
|
||||||
|
///
|
||||||
|
/// Two pixel spaces meet here, exactly as they do in the GLFW and ImGui adapters.
|
||||||
|
/// Qt reports widget geometry and mouse positions in **logical** pixels
|
||||||
|
/// (`width()`, `QMouseEvent::position()`), while the widget's framebuffer -- what
|
||||||
|
/// `Renderer::begin_frame()` takes and what every `Rect` in this library speaks --
|
||||||
|
/// is in **device** pixels, `devicePixelRatioF()` times as large on a scaled
|
||||||
|
/// display.
|
||||||
|
///
|
||||||
|
/// This widget converts everything into device pixels and keeps it there:
|
||||||
|
///
|
||||||
|
/// * the viewport handed to the plot is `round(width() * dpr)` x
|
||||||
|
/// `round(height() * dpr)` (rounded, not truncated, so the last column of the
|
||||||
|
/// FBO is inside the plot), and
|
||||||
|
/// * every pointer, wheel and key anchor is multiplied by the same
|
||||||
|
/// `devicePixelRatioF()` before it reaches the controller, deltas included.
|
||||||
|
///
|
||||||
|
/// `device_viewport()` and `to_device()` are the only two places that mapping
|
||||||
|
/// exists, so the viewport and the input cannot disagree -- and they must not: a
|
||||||
|
/// mismatch renders correctly but pans by the wrong amount, which is invisible on
|
||||||
|
/// an unscaled display and wrong on every other one.
|
||||||
|
///
|
||||||
|
/// ## Input
|
||||||
|
///
|
||||||
|
/// Qt reports levels and edges directly (`QMouseEvent` press/release are edges,
|
||||||
|
/// `buttons()` is the state after the event), so the translation is a
|
||||||
|
/// reinterpretation rather than the edge synthesis the ImGui adapter needs.
|
||||||
|
/// Wheel input is the one unit conversion: Qt's `angleDelta()` is in eighths of a
|
||||||
|
/// degree, 120 to a notch, while `Controller::on_wheel()` takes notches, positive
|
||||||
|
/// = zoom in -- which is Qt's positive `angleDelta().y()` too. A wheel event with
|
||||||
|
/// no vertical component is refused before it reaches the controller, because a
|
||||||
|
/// zero-notch zoom changes no range but still runs the range setters, which clear
|
||||||
|
/// auto scale: a horizontal scroll must not silently stop a live axis following.
|
||||||
|
///
|
||||||
|
/// ## Threading
|
||||||
|
///
|
||||||
|
/// Qt widgets live on the GUI thread, and so does everything here: `initializeGL`,
|
||||||
|
/// `paintGL` and the event handlers all run there, which is the thread that owns
|
||||||
|
/// the context. A host that feeds the plot from a producer thread acquires its
|
||||||
|
/// data on this thread (see the frame callback).
|
||||||
|
class PlotWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
/// One frame of host code, run by `paintGL()` after the viewport is set and
|
||||||
|
/// before the frame is staged. It is where data is refreshed -- acquiring a
|
||||||
|
/// `data::StreamSeries`, re-fitting a live axis, moving cursors -- and `dt` is
|
||||||
|
/// the wall time since the previous call, in seconds. Optional; a widget with
|
||||||
|
/// no callback just re-draws the plot as it stands.
|
||||||
|
using FrameFn = std::function<void(Plot&, f64)>;
|
||||||
|
|
||||||
|
/// Renders and drives `plot`, which must outlive this widget.
|
||||||
|
explicit PlotWidget(Plot& plot, QWidget* parent = nullptr);
|
||||||
|
~PlotWidget() override;
|
||||||
|
|
||||||
|
PlotWidget(const PlotWidget&) = delete;
|
||||||
|
PlotWidget& operator=(const PlotWidget&) = delete;
|
||||||
|
|
||||||
|
void set_frame_callback(FrameFn frame) { frame_ = std::move(frame); }
|
||||||
|
|
||||||
|
/// The plot this widget renders. Valid for the widget's whole life.
|
||||||
|
Plot& plot() noexcept { return *plot_; }
|
||||||
|
const Plot& plot() const noexcept { return *plot_; }
|
||||||
|
/// The controller the widget feeds, bound to `plot()`.
|
||||||
|
input::Controller& controller() noexcept { return controller_; }
|
||||||
|
const input::Controller& controller() const noexcept { return controller_; }
|
||||||
|
|
||||||
|
/// The renderer, or null until a successful `initializeGL()`. Valid from the
|
||||||
|
/// corresponding `paintGL()` onwards, and after the widget's context exists.
|
||||||
|
Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; }
|
||||||
|
const Renderer* renderer() const noexcept { return renderer_ ? &*renderer_ : nullptr; }
|
||||||
|
|
||||||
|
/// What `initializeGL()` made of the context: `Ok`, or the failure that left
|
||||||
|
/// the widget drawing nothing. A `QOpenGLWidget` virtual cannot return a
|
||||||
|
/// `Status`, and a widget must not throw across Qt's callback, so the outcome
|
||||||
|
/// is recorded here instead.
|
||||||
|
const Status& init_status() const noexcept { return init_status_; }
|
||||||
|
|
||||||
|
/// Frames actually staged, and the frame time of the last one (ms). The mean is
|
||||||
|
/// an exponential moving average seeded by the first frame, of the same
|
||||||
|
/// render-only span: the frame callback, `begin_frame`, `draw` and `end_frame`.
|
||||||
|
u64 frames_painted() const noexcept { return frames_; }
|
||||||
|
f32 last_frame_ms() const noexcept { return last_frame_ms_; }
|
||||||
|
f32 mean_frame_ms() const noexcept { return mean_frame_ms_; }
|
||||||
|
|
||||||
|
/// The region the last frame was drawn into, in device pixels -- the rect
|
||||||
|
/// `begin_frame()` was handed. Zero-sized before the first frame.
|
||||||
|
Rect last_device_viewport() const noexcept { return last_viewport_; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void initializeGL() override;
|
||||||
|
void resizeGL(int w, int h) override;
|
||||||
|
void paintGL() override;
|
||||||
|
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
|
/// Qt reports the second press of a double click as this event and not as a
|
||||||
|
/// press, so without it a drag that began with a double click would have no
|
||||||
|
/// press to start from. Forwarded as one.
|
||||||
|
void mouseDoubleClickEvent(QMouseEvent* event) override;
|
||||||
|
void wheelEvent(QWheelEvent* event) override;
|
||||||
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// The widget's rectangle in device pixels: the plot's viewport, and the size
|
||||||
|
/// of the framebuffer the frame is drawn into.
|
||||||
|
Rect device_viewport() const noexcept;
|
||||||
|
/// A Qt position (logical widget pixels) in device pixels. The one place the
|
||||||
|
/// input mapping exists, so every event is scaled by the same factor the
|
||||||
|
/// viewport is.
|
||||||
|
Vec2 to_device(const QPointF& p) const noexcept;
|
||||||
|
/// Translates one mouse event and hands it to the controller. `pressed` and
|
||||||
|
/// `released` are the transitions Qt reports on the event itself.
|
||||||
|
void forward_pointer(QMouseEvent* event, bool pressed, bool released);
|
||||||
|
/// Sets the plot's viewport to the current device rect (through the controller,
|
||||||
|
/// which refreshes the layout with it).
|
||||||
|
void refresh_viewport();
|
||||||
|
|
||||||
|
Plot* plot_ = nullptr; ///< declared first: the controller is built over it
|
||||||
|
input::Controller controller_;
|
||||||
|
std::optional<Renderer> renderer_;
|
||||||
|
FrameFn frame_;
|
||||||
|
Status init_status_;
|
||||||
|
|
||||||
|
/// Last pointer position in device pixels: the deltas the controller pans with
|
||||||
|
/// are the movement since the previous event, which Qt does not report.
|
||||||
|
bool have_pointer_ = false;
|
||||||
|
f32 last_x_ = 0.0f;
|
||||||
|
f32 last_y_ = 0.0f;
|
||||||
|
|
||||||
|
u64 frames_ = 0;
|
||||||
|
f32 last_frame_ms_ = 0.0f;
|
||||||
|
f32 mean_frame_ms_ = 0.0f;
|
||||||
|
/// Wall clock at the last `paintGL()` start, or negative before the first one.
|
||||||
|
f64 last_paint_s_ = -1.0;
|
||||||
|
Rect last_viewport_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace plot::qt
|
||||||
@@ -21,3 +21,12 @@ if(TARGET plot::imgui)
|
|||||||
Threads::Threads)
|
Threads::Threads)
|
||||||
plot_apply_warnings(plot_imgui_demo)
|
plot_apply_warnings(plot_imgui_demo)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(TARGET plot::qt6)
|
||||||
|
# The demo owns the QApplication, the QMainWindow and the toolbar; the adapter is
|
||||||
|
# only the widget, which the window adopts as its central widget. No macro
|
||||||
|
# processing is needed here: the demo declares no Qt class of its own.
|
||||||
|
add_executable(plot_qt_demo qt_demo.cpp)
|
||||||
|
target_link_libraries(plot_qt_demo PRIVATE plot::qt6 Threads::Threads)
|
||||||
|
plot_apply_warnings(plot_qt_demo)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
// The Qt application: the same 1 MS/s stream the other demos draw, as the central
|
||||||
|
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
|
||||||
|
// window chrome around a plot the widget renders into its own framebuffer.
|
||||||
|
//
|
||||||
|
// What it demonstrates:
|
||||||
|
//
|
||||||
|
// - `plot::qt::PlotWidget`: a QOpenGLWidget whose FBO the plot is drawn into
|
||||||
|
// (no texture, no offscreen pass of its own), driven by Qt's mouse, wheel and
|
||||||
|
// key events;
|
||||||
|
// - the surface format an application must have set *before* the widget exists --
|
||||||
|
// a `QOpenGLWidget` is handed the application default format and cannot ask for
|
||||||
|
// another one afterwards -- and `Qt::AA_UseDesktopOpenGL`, so Qt cannot pick an
|
||||||
|
// ES or ANGLE backend that has no 3.3 core profile to give;
|
||||||
|
// - the device-pixel-ratio mapping: Qt lays out and reports in logical pixels,
|
||||||
|
// the widget's framebuffer is in device pixels, and the widget converts both the
|
||||||
|
// viewport and every input coordinate with the same factor (see the header);
|
||||||
|
// - the demo-side controls the widget knows nothing about -- Fit, Autoscale and
|
||||||
|
// Add cursor on the toolbar;
|
||||||
|
// - a scripted resize before the exit frame, so the headless run proves the widget
|
||||||
|
// re-lays the plot out for the new geometry rather than scaling a stale one;
|
||||||
|
// - the headless smoke mode every demo here supports
|
||||||
|
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT). The screenshot is the whole
|
||||||
|
// *widget*, via `QOpenGLWidget::grabFramebuffer()` (dump_widget_ppm below), so
|
||||||
|
// the PPM holds the plot's own chrome; the window's toolbar is Qt's, and is not
|
||||||
|
// in the widget's framebuffer.
|
||||||
|
//
|
||||||
|
// The producer is joined before the stream it writes into is destroyed, and before
|
||||||
|
// the widget's GL resources are released.
|
||||||
|
|
||||||
|
// The widget's header first: it brings this library's GL surface in before Qt's
|
||||||
|
// <GL/gl.h> macros exist (see the include-order note there).
|
||||||
|
#include <qt6/qt_plot_widget.hpp>
|
||||||
|
|
||||||
|
#include <plot/data/stream_series.hpp>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QSurfaceFormat>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QToolBar>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <numbers>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using plot::f32;
|
||||||
|
using plot::f64;
|
||||||
|
using plot::u32;
|
||||||
|
using plot::u64;
|
||||||
|
|
||||||
|
/// One sample per microsecond: 1 MS/s.
|
||||||
|
constexpr f64 kDx = 1.0e-6;
|
||||||
|
/// The retained window: 1 << 20 samples = 1.048576 s.
|
||||||
|
constexpr u64 kCapacity = 1u << 20;
|
||||||
|
/// Producer wakeup: about a millisecond of samples per push batch.
|
||||||
|
constexpr u64 kBatch = 1u << 10;
|
||||||
|
/// Cap on one push batch, so a process that was stopped and resumed catches up
|
||||||
|
/// over several iterations instead of allocating a batch for the whole gap.
|
||||||
|
constexpr u64 kMaxBatch = 1u << 16;
|
||||||
|
|
||||||
|
constexpr f64 kSineHz = 437.0;
|
||||||
|
constexpr f64 kNoiseAmp = 0.02;
|
||||||
|
constexpr f64 kSpikePeriodS = 0.1;
|
||||||
|
constexpr f64 kSpikeWidthS = 2.0e-6;
|
||||||
|
constexpr f64 kSpikeAmp = 3.0;
|
||||||
|
|
||||||
|
/// The trace colour the render smoke test's blue-dominance predicate expects, so
|
||||||
|
/// the same predicate reads this demo's screenshot.
|
||||||
|
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
|
||||||
|
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
|
||||||
|
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
|
||||||
|
/// The colours "Add cursor" cycles through.
|
||||||
|
constexpr plot::Color kExtraCursorColors[] = {plot::Color::hex(0xFF6B6B),
|
||||||
|
plot::Color::hex(0xB08CFF),
|
||||||
|
plot::Color::hex(0x6BE3FF),
|
||||||
|
plot::Color::hex(0xE8E86B)};
|
||||||
|
|
||||||
|
/// Logical pixels. The widget's framebuffer is `devicePixelRatioF()` times this.
|
||||||
|
constexpr int kWindowWidth = 1200;
|
||||||
|
constexpr int kWindowHeight = 800;
|
||||||
|
/// The size the scripted resize before the exit frame goes to: different on both
|
||||||
|
/// axes, so a screenshot that kept the old layout is visibly the old size.
|
||||||
|
constexpr int kExitWidth = 820;
|
||||||
|
constexpr int kExitHeight = 620;
|
||||||
|
|
||||||
|
/// y(t) for sample index `i`: sine + deterministic noise + a narrow spike, the
|
||||||
|
/// signal the other demos use.
|
||||||
|
f32 sample_at(u64 i) noexcept {
|
||||||
|
const f64 t = static_cast<f64>(i) * kDx;
|
||||||
|
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
|
||||||
|
|
||||||
|
u32 h = static_cast<u32>(i) * 2654435761u;
|
||||||
|
h ^= h >> 15;
|
||||||
|
h *= 2246822519u;
|
||||||
|
h ^= h >> 13;
|
||||||
|
h *= 3266489917u;
|
||||||
|
h ^= h >> 16;
|
||||||
|
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
|
||||||
|
|
||||||
|
const f64 phase = std::fmod(t, kSpikePeriodS);
|
||||||
|
if (phase < kSpikeWidthS) {
|
||||||
|
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
|
||||||
|
}
|
||||||
|
return static_cast<f32>(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feeds a `StreamSeries` from its own thread at 1 MS/s on average, paced by
|
||||||
|
/// absolute deadlines off a steady clock: every wakeup produces every sample
|
||||||
|
/// whose time has already come, so a late wakeup is caught up instead of pushing
|
||||||
|
/// the phase back.
|
||||||
|
class Producer {
|
||||||
|
public:
|
||||||
|
explicit Producer(plot::data::StreamSeries& series)
|
||||||
|
: series_(&series), thread_([this] { run(); }) {}
|
||||||
|
~Producer() { stop(); }
|
||||||
|
|
||||||
|
Producer(const Producer&) = delete;
|
||||||
|
Producer& operator=(const Producer&) = delete;
|
||||||
|
|
||||||
|
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls it,
|
||||||
|
/// so the thread is never detached and never outlives the stream.
|
||||||
|
void stop() noexcept {
|
||||||
|
stop_.store(true, std::memory_order_relaxed);
|
||||||
|
if (thread_.joinable()) thread_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void run() {
|
||||||
|
using clock = std::chrono::steady_clock;
|
||||||
|
const auto start = clock::now();
|
||||||
|
u64 next = 0;
|
||||||
|
while (!stop_.load(std::memory_order_relaxed)) {
|
||||||
|
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
|
||||||
|
const u64 due = static_cast<u64>(elapsed / kDx) + 1;
|
||||||
|
if (due > next) {
|
||||||
|
u64 n = due - next;
|
||||||
|
if (n > kMaxBatch) n = kMaxBatch;
|
||||||
|
batch_.resize(static_cast<plot::usize>(n));
|
||||||
|
for (u64 k = 0; k < n; ++k) {
|
||||||
|
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
|
||||||
|
}
|
||||||
|
series_->push(batch_.data(), n);
|
||||||
|
next += n;
|
||||||
|
produced_.store(next, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
const f64 wake_s = static_cast<f64>(next + kBatch) * kDx;
|
||||||
|
std::this_thread::sleep_until(
|
||||||
|
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(wake_s)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plot::data::StreamSeries* series_;
|
||||||
|
std::vector<f32> batch_;
|
||||||
|
std::atomic<u64> produced_{0};
|
||||||
|
std::atomic<bool> stop_{false};
|
||||||
|
std::thread thread_; ///< declared last: the lambda runs once everything is up
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The live readout, refreshed a few times a second.
|
||||||
|
struct Meter {
|
||||||
|
f64 elapsed = 0.0;
|
||||||
|
f64 last_report = 0.0;
|
||||||
|
u64 last_produced = 0;
|
||||||
|
f64 rate = 0.0; ///< samples/s over the last metered interval
|
||||||
|
u64 frames = 0; ///< frame callbacks run (one per paint)
|
||||||
|
};
|
||||||
|
|
||||||
|
f64 now_s() noexcept {
|
||||||
|
return std::chrono::duration<f64>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
|
||||||
|
/// positive, which is "run until the window is closed".
|
||||||
|
f64 exit_after_seconds() noexcept {
|
||||||
|
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
|
||||||
|
if (v == nullptr || v[0] == '\0') return 0.0;
|
||||||
|
char* end = nullptr;
|
||||||
|
const f64 ms = std::strtod(v, &end);
|
||||||
|
if (end == v || !(ms > 0.0)) return 0.0;
|
||||||
|
return ms * 1.0e-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `image` out as a binary PPM (P6, 8 bits per channel, rows top-down).
|
||||||
|
///
|
||||||
|
/// Demo-side, not library API: `Renderer::dump_frame_ppm()` covers the plot's
|
||||||
|
/// viewport only, and this smoke mode has to show the widget as Qt composes it.
|
||||||
|
/// `QOpenGLWidget::grabFramebuffer()` hands back the widget's FBO in Qt's default
|
||||||
|
/// 32-bit format, which on a little-endian machine stores each pixel as
|
||||||
|
/// B,G,R,A -- not the R,G,B a PPM holds -- so the image is converted to
|
||||||
|
/// `QImage::Format_RGB888` first, the one Qt format whose in-memory byte order is
|
||||||
|
/// the file's. Scanlines are padded to a 4-byte boundary whatever the format, so
|
||||||
|
/// each row is copied through `constScanLine()` rather than assuming a tightly
|
||||||
|
/// packed block.
|
||||||
|
bool dump_widget_ppm(const QImage& image, const char* path) {
|
||||||
|
if (image.isNull() || path == nullptr || path[0] == '\0') return false;
|
||||||
|
const QImage rgb = image.format() == QImage::Format_RGB888
|
||||||
|
? image
|
||||||
|
: image.convertToFormat(QImage::Format_RGB888);
|
||||||
|
if (rgb.isNull()) return false;
|
||||||
|
|
||||||
|
std::FILE* file = std::fopen(path, "wb");
|
||||||
|
if (file == nullptr) return false;
|
||||||
|
if (std::fprintf(file, "P6\n%d %d\n255\n", rgb.width(), rgb.height()) < 0) {
|
||||||
|
std::fclose(file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const plot::usize row_bytes = static_cast<plot::usize>(rgb.width()) * 3u;
|
||||||
|
for (int y = 0; y < rgb.height(); ++y) {
|
||||||
|
if (std::fwrite(rgb.constScanLine(y), 1u, row_bytes, file) != row_bytes) {
|
||||||
|
std::fclose(file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::fclose(file) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
const f64 exit_after_s = exit_after_seconds();
|
||||||
|
const bool automated = exit_after_s > 0.0;
|
||||||
|
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
|
||||||
|
const f64 run_start = now_s();
|
||||||
|
|
||||||
|
// --- the surface format, before anything can create a context -----------
|
||||||
|
// Qt gives a widget the *application default* format at the moment its context
|
||||||
|
// is created and never renegotiates it, so this has to happen before the widget
|
||||||
|
// exists -- and, on some platforms, before the QApplication itself.
|
||||||
|
QSurfaceFormat format;
|
||||||
|
format.setVersion(3, 3);
|
||||||
|
format.setProfile(QSurfaceFormat::CoreProfile);
|
||||||
|
format.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||||||
|
// The plot is 2D: the renderer never sets a depth test and never touches the
|
||||||
|
// stencil, so asking for either would only cost memory.
|
||||||
|
format.setDepthBufferSize(0);
|
||||||
|
format.setStencilBufferSize(0);
|
||||||
|
QSurfaceFormat::setDefaultFormat(format);
|
||||||
|
|
||||||
|
// ...and it has to be *desktop* GL: an ES or ANGLE backend has no 3.3 core
|
||||||
|
// profile to give, and this library's loader resolves desktop entry points.
|
||||||
|
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL);
|
||||||
|
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
|
||||||
|
// --- the stream and the plot the widget draws --------------------------
|
||||||
|
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
|
||||||
|
Producer producer(stream);
|
||||||
|
|
||||||
|
plot::Plot graph;
|
||||||
|
// Time axis: clock labels, which `TickFormat::Time` routes through
|
||||||
|
// `format_time`. The value axis keeps SI labels, which is `format_si`.
|
||||||
|
const u32 axis_x = graph.add_x_axis(
|
||||||
|
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||||
|
const u32 axis_y = graph.add_y_axis(
|
||||||
|
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||||
|
const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
|
||||||
|
|
||||||
|
// Snapped cursors sit exactly on a sample, so the readout measures samples and
|
||||||
|
// not a rounded position between two of them. The pair and its measurement are
|
||||||
|
// plot state; the toolbar's "Add cursor" adds more.
|
||||||
|
const u32 cursor_a = graph.add_cursor(0.0, kCursorAColor);
|
||||||
|
const u32 cursor_b = graph.add_cursor(0.0, kCursorBColor);
|
||||||
|
graph.set_cursor_snap(cursor_a, plot::SnapMode::NearestSample);
|
||||||
|
graph.set_cursor_snap(cursor_b, plot::SnapMode::NearestSample);
|
||||||
|
const u32 measure = graph.add_measure(cursor_a, cursor_b, trace);
|
||||||
|
|
||||||
|
// --- the window, its toolbar and the widget ----------------------------
|
||||||
|
QMainWindow window;
|
||||||
|
window.setWindowTitle("plot qt demo");
|
||||||
|
// The widget is Qt's to own from here on: `setCentralWidget()` reparents it, so
|
||||||
|
// ~QMainWindow deletes it -- which is also what releases the plot's GL resources
|
||||||
|
// while the context is still alive, since `window` goes out of scope before
|
||||||
|
// `graph` does.
|
||||||
|
auto* const widget = new plot::qt::PlotWidget(graph);
|
||||||
|
window.setCentralWidget(widget);
|
||||||
|
|
||||||
|
QToolBar* const tools = window.addToolBar("plot");
|
||||||
|
tools->setMovable(false);
|
||||||
|
QAction* const fit_action = tools->addAction("Fit");
|
||||||
|
QAction* const autoscale_action = tools->addAction("Autoscale");
|
||||||
|
autoscale_action->setCheckable(true);
|
||||||
|
autoscale_action->setChecked(true);
|
||||||
|
QAction* const add_cursor_action = tools->addAction("Add cursor");
|
||||||
|
|
||||||
|
// A toolbar click moves focus to the tool button, which would take the
|
||||||
|
// controller's keys away from the plot, so every action hands focus back.
|
||||||
|
const auto keep_focus = [widget] { widget->setFocus(); };
|
||||||
|
|
||||||
|
Meter meter;
|
||||||
|
// The cursors ride the live window until the user takes over: the first
|
||||||
|
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
|
||||||
|
// stops them where the user put them.
|
||||||
|
bool cursors_follow = true;
|
||||||
|
plot::usize extra_cursor = 0;
|
||||||
|
|
||||||
|
widget->set_frame_callback([&](plot::Plot& p, plot::f64 dt) {
|
||||||
|
stream.acquire(); // once per frame, on the render thread
|
||||||
|
|
||||||
|
// Re-frame the live stream while auto scale is armed. `auto_fit_*` re-arms it,
|
||||||
|
// so this may only run while it is still on: calling it after a pan would undo
|
||||||
|
// the pan every frame.
|
||||||
|
if (p.x_axis(axis_x).auto_scale) p.auto_fit_x();
|
||||||
|
if (p.y_axis(axis_y).auto_scale) p.auto_fit_y(axis_y);
|
||||||
|
|
||||||
|
if (cursors_follow) {
|
||||||
|
if (widget->controller().drag_mode() == plot::input::DragMode::MoveCursor ||
|
||||||
|
!p.x_axis(axis_x).auto_scale) {
|
||||||
|
cursors_follow = false;
|
||||||
|
} else {
|
||||||
|
const plot::Range xr = p.x_range(axis_x);
|
||||||
|
p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
|
||||||
|
p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
meter.frames += 1;
|
||||||
|
meter.elapsed += dt;
|
||||||
|
if (meter.elapsed - meter.last_report >= 0.5) {
|
||||||
|
const u64 produced = producer.produced();
|
||||||
|
meter.rate = static_cast<f64>(produced - meter.last_produced) /
|
||||||
|
(meter.elapsed - meter.last_report);
|
||||||
|
meter.last_produced = produced;
|
||||||
|
meter.last_report = meter.elapsed;
|
||||||
|
const plot::Rect viewport = widget->last_device_viewport();
|
||||||
|
std::printf("[qt_demo] t=%.1fs %.3f MS/s %.2f ms/frame %.2f dpr fbo %ux%u %llu frames\n",
|
||||||
|
meter.elapsed, meter.rate * 1.0e-6, static_cast<double>(widget->mean_frame_ms()),
|
||||||
|
static_cast<double>(widget->devicePixelRatioF()),
|
||||||
|
static_cast<unsigned>(viewport.w), static_cast<unsigned>(viewport.h),
|
||||||
|
static_cast<unsigned long long>(meter.frames));
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The toolbar's actions are the demo's, not the widget's: the plot is the only
|
||||||
|
// thing that knows what fitting, following or cursoring means.
|
||||||
|
QObject::connect(fit_action, &QAction::triggered, &window, [&] {
|
||||||
|
keep_focus();
|
||||||
|
// One-shot: frame the data now. `auto_fit_*` re-arms auto scale, which the
|
||||||
|
// frame callback then follows, so this is also how a pan is undone.
|
||||||
|
graph.auto_fit_x();
|
||||||
|
graph.auto_fit_y(axis_y);
|
||||||
|
});
|
||||||
|
QObject::connect(autoscale_action, &QAction::toggled, &window, [&](bool on) {
|
||||||
|
keep_focus();
|
||||||
|
// Auto scale is what the frame callback follows; arming it here is what makes
|
||||||
|
// checking the box resume the live framing after a pan.
|
||||||
|
graph.set_x_auto_scale(axis_x, on);
|
||||||
|
graph.set_y_auto_scale(axis_y, on);
|
||||||
|
});
|
||||||
|
QObject::connect(add_cursor_action, &QAction::triggered, &window, [&] {
|
||||||
|
keep_focus();
|
||||||
|
const plot::Range xr = graph.x_range(axis_x);
|
||||||
|
const plot::Color color =
|
||||||
|
kExtraCursorColors[extra_cursor % (sizeof kExtraCursorColors / sizeof kExtraCursorColors[0])];
|
||||||
|
graph.add_cursor(xr.min + 0.5 * xr.span(), color);
|
||||||
|
++extra_cursor;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.resize(kWindowWidth, kWindowHeight);
|
||||||
|
window.show();
|
||||||
|
widget->setFocus(); // so the controller's keys reach the plot
|
||||||
|
|
||||||
|
// The repaint loop. A QOpenGLWidget only draws when Qt asks it to, so something
|
||||||
|
// has to ask: a 60 Hz timer is the display rate this demo runs at (a real
|
||||||
|
// application drives the same timer, or its toolkit's equivalent, from whatever
|
||||||
|
// it animates). Nothing here is library API -- `update()` is Qt's.
|
||||||
|
QTimer repaint_timer;
|
||||||
|
repaint_timer.setTimerType(Qt::PreciseTimer);
|
||||||
|
QObject::connect(&repaint_timer, &QTimer::timeout, widget, [widget] { widget->update(); });
|
||||||
|
repaint_timer.start(16); // ~60 Hz
|
||||||
|
|
||||||
|
// --- the headless exit path -------------------------------------------
|
||||||
|
QTimer exit_timer;
|
||||||
|
QObject::connect(&exit_timer, &QTimer::timeout, &app, [&] {
|
||||||
|
if (!automated || now_s() - run_start < exit_after_s) return;
|
||||||
|
exit_timer.stop();
|
||||||
|
|
||||||
|
const plot::PlotLayout before = std::as_const(graph).layout();
|
||||||
|
const QSize logical_before = widget->size();
|
||||||
|
|
||||||
|
// Scripted resize before the exit frame: a screenshot taken after the widget
|
||||||
|
// has been resized is what proves the widget re-lays the plot out for the new
|
||||||
|
// geometry instead of scaling a stale one. The resize is asynchronous (it goes
|
||||||
|
// through the window system), so the event queue is drained before the grab.
|
||||||
|
window.resize(kExitWidth, kExitHeight);
|
||||||
|
for (int i = 0; i < 8; ++i) QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Renders the widget at its current size and reads the FBO back, so the image
|
||||||
|
// is the widget as Qt composes it -- including the frames painted by the
|
||||||
|
// resize above.
|
||||||
|
const QImage image = widget->grabFramebuffer();
|
||||||
|
const plot::PlotLayout after = std::as_const(graph).layout();
|
||||||
|
|
||||||
|
std::printf("\n[qt_demo] ---- exit frame ----\n");
|
||||||
|
std::printf("[qt_demo] widget %.2f dpr, logical %dx%d -> %dx%d, fbo %ux%u\n",
|
||||||
|
static_cast<double>(widget->devicePixelRatioF()), logical_before.width(),
|
||||||
|
logical_before.height(), widget->width(), widget->height(),
|
||||||
|
static_cast<unsigned>(widget->last_device_viewport().w),
|
||||||
|
static_cast<unsigned>(widget->last_device_viewport().h));
|
||||||
|
std::printf("[qt_demo] grabbed %dx%d px, image dpr %.2f\n", image.width(), image.height(),
|
||||||
|
image.isNull() ? 0.0 : static_cast<double>(image.devicePixelRatio()));
|
||||||
|
std::printf("[qt_demo] layout axes before %.0fx%.0f at (%.0f, %.0f), after %.0fx%.0f at"
|
||||||
|
" (%.0f, %.0f)\n",
|
||||||
|
static_cast<double>(before.axes.w), static_cast<double>(before.axes.h),
|
||||||
|
static_cast<double>(before.axes.x), static_cast<double>(before.axes.y),
|
||||||
|
static_cast<double>(after.axes.w), static_cast<double>(after.axes.h),
|
||||||
|
static_cast<double>(after.axes.x), static_cast<double>(after.axes.y));
|
||||||
|
|
||||||
|
if (screenshot != nullptr && screenshot[0] != '\0') {
|
||||||
|
const bool written = dump_widget_ppm(image, screenshot);
|
||||||
|
std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED");
|
||||||
|
}
|
||||||
|
std::printf("[qt_demo] init %s: %s\n", plot::to_string(widget->init_status().code),
|
||||||
|
widget->init_status().message);
|
||||||
|
|
||||||
|
// --- scripted input check --------------------------------------------
|
||||||
|
// The widget's own event handlers are driven with synthetic events, because
|
||||||
|
// the one bug the picture cannot show is the coordinate convention: a widget
|
||||||
|
// that scales the viewport but not the pointer renders correctly and pans by
|
||||||
|
// the wrong amount. Both boxes below are specified in LOGICAL pixels and
|
||||||
|
// turned into the device-pixel expectation the widget must produce, so at a
|
||||||
|
// scale factor other than 1.0 an unscaled event lands 1.25x off and the
|
||||||
|
// comparison fails.
|
||||||
|
const plot::Rect check_axes = std::as_const(graph).layout().axes; // device pixels
|
||||||
|
const f64 check_dpr = static_cast<f64>(widget->devicePixelRatioF());
|
||||||
|
const auto to_logical = [check_dpr](f32 device_x, f32 device_y) {
|
||||||
|
return QPointF(static_cast<qreal>(device_x) / check_dpr,
|
||||||
|
static_cast<qreal>(device_y) / check_dpr);
|
||||||
|
};
|
||||||
|
const auto send_mouse = [widget](QEvent::Type type, QPointF at, Qt::MouseButton button,
|
||||||
|
Qt::MouseButtons held, Qt::KeyboardModifiers mods) {
|
||||||
|
QMouseEvent event(type, at, at, button, held, mods);
|
||||||
|
QCoreApplication::sendEvent(widget, &event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A shift+drag box inside the axes: the resulting x range must be exactly the
|
||||||
|
// data the box covered on screen.
|
||||||
|
const plot::Rect box{check_axes.x + 0.25f * check_axes.w, check_axes.y + 0.25f * check_axes.h,
|
||||||
|
0.5f * check_axes.w, 0.5f * check_axes.h};
|
||||||
|
const plot::Transform x_before = graph.x_transform(axis_x);
|
||||||
|
const f64 box_expect_min = x_before.to_data_x(static_cast<f64>(box.x));
|
||||||
|
const f64 box_expect_max = x_before.to_data_x(static_cast<f64>(box.right()));
|
||||||
|
{
|
||||||
|
const QPointF a = to_logical(box.x, box.y);
|
||||||
|
const QPointF b = to_logical(box.right(), box.bottom());
|
||||||
|
send_mouse(QEvent::MouseButtonPress, a, Qt::LeftButton, Qt::LeftButton, Qt::ShiftModifier);
|
||||||
|
send_mouse(QEvent::MouseMove, b, Qt::NoButton, Qt::LeftButton, Qt::ShiftModifier);
|
||||||
|
send_mouse(QEvent::MouseButtonRelease, b, Qt::LeftButton, Qt::NoButton, Qt::ShiftModifier);
|
||||||
|
}
|
||||||
|
const plot::Range box_got = graph.x_range(axis_x);
|
||||||
|
const f64 box_tol = 1.0e-6 * (box_expect_max - box_expect_min);
|
||||||
|
const bool box_ok = std::fabs(box_got.min - box_expect_min) <= box_tol &&
|
||||||
|
std::fabs(box_got.max - box_expect_max) <= box_tol;
|
||||||
|
|
||||||
|
// A plain drag then translates the range by the drag's own fraction of the
|
||||||
|
// axes and must not change its span: the deltas the controller pans with come
|
||||||
|
// from two scaled positions, so this is what pins the delta path too. The
|
||||||
|
// axes rect is read again, through the *recomputing* layout(), on purpose: the
|
||||||
|
// box zoom changed the y tick labels, the narrower label strip gave the axes
|
||||||
|
// more width, and that width is what the controller pans by -- it refreshes the
|
||||||
|
// layout itself, in on_pointer, before it acts.
|
||||||
|
const plot::Rect pan_axes = graph.layout().axes;
|
||||||
|
const f64 drag_device_dx = 0.2 * static_cast<f64>(pan_axes.w);
|
||||||
|
const plot::Range before_drag = graph.x_range(axis_x);
|
||||||
|
const f64 drag_expect_min =
|
||||||
|
before_drag.min - drag_device_dx / static_cast<f64>(pan_axes.w) * before_drag.span();
|
||||||
|
{
|
||||||
|
const f32 x0 = pan_axes.x + 0.4f * pan_axes.w;
|
||||||
|
const f32 y0 = pan_axes.y + 0.5f * pan_axes.h;
|
||||||
|
const QPointF a = to_logical(x0, y0);
|
||||||
|
const QPointF b = to_logical(x0 + static_cast<f32>(drag_device_dx), y0);
|
||||||
|
send_mouse(QEvent::MouseButtonPress, a, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
|
||||||
|
send_mouse(QEvent::MouseMove, b, Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||||
|
send_mouse(QEvent::MouseButtonRelease, b, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
|
||||||
|
}
|
||||||
|
const plot::Range drag_got = graph.x_range(axis_x);
|
||||||
|
const f64 drag_tol = 1.0e-6 * before_drag.span();
|
||||||
|
const bool drag_ok = std::fabs(drag_got.min - drag_expect_min) <= drag_tol &&
|
||||||
|
std::fabs(drag_got.span() - before_drag.span()) <= drag_tol;
|
||||||
|
|
||||||
|
std::printf("[qt_demo] input check dpr %.2f, axes %.0fx%.0f device px\n", check_dpr,
|
||||||
|
static_cast<double>(check_axes.w), static_cast<double>(check_axes.h));
|
||||||
|
std::printf("[qt_demo] input check box-zoom %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
|
||||||
|
box_ok ? "PASS" : "FAIL", box_expect_min, box_expect_max, box_got.min, box_got.max);
|
||||||
|
std::printf("[qt_demo] input check pan %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
|
||||||
|
drag_ok ? "PASS" : "FAIL", drag_expect_min, drag_expect_min + before_drag.span(),
|
||||||
|
drag_got.min, drag_got.max);
|
||||||
|
|
||||||
|
// The two gestures above left the plot zoomed and panned (that is what makes
|
||||||
|
// them checks). Put the live framing back before the run summary reads the
|
||||||
|
// last frame's statistics.
|
||||||
|
graph.auto_fit_x();
|
||||||
|
graph.auto_fit_y(axis_y);
|
||||||
|
|
||||||
|
std::fflush(stdout);
|
||||||
|
QCoreApplication::quit();
|
||||||
|
});
|
||||||
|
if (automated) exit_timer.start(20);
|
||||||
|
|
||||||
|
const int app_result = app.exec();
|
||||||
|
|
||||||
|
// --- teardown and the run summary -------------------------------------
|
||||||
|
producer.stop(); // no thread pushes into the stream after this point
|
||||||
|
|
||||||
|
const plot::Renderer* const renderer = widget->renderer();
|
||||||
|
const plot::FrameStats stats = renderer != nullptr ? renderer->stats() : plot::FrameStats{};
|
||||||
|
const plot::Rect axes = std::as_const(graph).layout().axes;
|
||||||
|
const f64 spp =
|
||||||
|
axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) / static_cast<f64>(axes.w) : 0.0;
|
||||||
|
const f64 dense = renderer != nullptr ? static_cast<f64>(renderer->style().dense_threshold_spp)
|
||||||
|
: 0.0;
|
||||||
|
const plot::Measurement m = graph.measurement(measure);
|
||||||
|
const f64 mean_ms = widget->mean_frame_ms();
|
||||||
|
|
||||||
|
std::printf("\n[qt_demo] ---- run summary ----\n");
|
||||||
|
std::printf("[qt_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
|
||||||
|
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
|
||||||
|
std::printf("[qt_demo] frames %llu painted (%llu frame callbacks) in %.3f s, mean %.2f"
|
||||||
|
" ms/frame (%.1f fps), last %.2f ms\n",
|
||||||
|
static_cast<unsigned long long>(widget->frames_painted()),
|
||||||
|
static_cast<unsigned long long>(meter.frames), meter.elapsed,
|
||||||
|
static_cast<double>(mean_ms), mean_ms > 0.0f ? 1.0e3 / static_cast<double>(mean_ms) : 0.0,
|
||||||
|
static_cast<double>(widget->last_frame_ms()));
|
||||||
|
std::printf("[qt_demo] retained %llu samples, x [%.6f, %.6f] s\n",
|
||||||
|
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
|
||||||
|
stream.x_bounds().max);
|
||||||
|
std::printf("[qt_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s, visible %llu,"
|
||||||
|
" axes %.0fx%.0f, %u segments, %u draw calls\n",
|
||||||
|
spp, dense, stats.lod_traces > 0 ? "LOD pyramid" : "raw samples",
|
||||||
|
static_cast<unsigned long long>(stats.visible_samples),
|
||||||
|
static_cast<double>(axes.w), static_cast<double>(axes.h), stats.staged_segments,
|
||||||
|
stats.draw_calls);
|
||||||
|
std::printf("[qt_demo] measurement %s dx %g s dy %g rms %g mean %g\n",
|
||||||
|
m.valid ? "valid:" : "INVALID", m.dx, m.dy, m.rms, m.mean);
|
||||||
|
std::printf("[qt_demo] gl %s upload, gl error on the last frame %d\n",
|
||||||
|
renderer != nullptr && renderer->using_persistent_upload() ? "persistent-mapped ring"
|
||||||
|
: "orphan",
|
||||||
|
renderer != nullptr && renderer->last_frame_had_gl_error() ? 1 : 0);
|
||||||
|
std::fflush(stdout);
|
||||||
|
|
||||||
|
// What goes out of scope here, in order: `window`, taking the widget with it
|
||||||
|
// (so the plot's GL resources are released while their context is still alive,
|
||||||
|
// and before `graph`, which the widget points at), then `graph`, then the
|
||||||
|
// already-joined `producer`, then `stream` itself.
|
||||||
|
return app_result;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user