diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c99e78..2f03d1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,10 @@ if(PLOT_BUILD_IMGUI) add_subdirectory(adapters/imgui) endif() +if(PLOT_BUILD_QT6) + add_subdirectory(adapters/qt6) +endif() + if(PLOT_BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/CMakePresets.json b/CMakePresets.json index d7b8d4c..e0b70db 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -15,7 +15,8 @@ "CMAKE_BUILD_TYPE": "Debug", "PLOT_BUILD_GLFW": 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", "PLOT_BUILD_GLFW": true, "PLOT_BUILD_IMGUI": true, - "PLOT_BUILD_EXAMPLES": true + "PLOT_BUILD_EXAMPLES": true, + "PLOT_BUILD_QT6": true } } ], diff --git a/adapters/qt6/CMakeLists.txt b/adapters/qt6/CMakeLists.txt new file mode 100644 index 0000000..8c93951 --- /dev/null +++ b/adapters/qt6/CMakeLists.txt @@ -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 +# -- 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) diff --git a/adapters/qt6/qt_plot_widget.cpp b/adapters/qt6/qt_plot_widget.cpp new file mode 100644 index 0000000..0726286 --- /dev/null +++ b/adapters/qt6/qt_plot_widget.cpp @@ -0,0 +1,291 @@ +#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; + 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(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) { + 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(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 diff --git a/adapters/qt6/qt_plot_widget.hpp b/adapters/qt6/qt_plot_widget.hpp new file mode 100644 index 0000000..dc32cc3 --- /dev/null +++ b/adapters/qt6/qt_plot_widget.hpp @@ -0,0 +1,208 @@ +#pragma once + +// The plot headers come first, and must. Qt's reaches +// , which on a desktop platform includes the system ; +// 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 +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +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; + + /// 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_; + 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 diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 03ccea5..1595d08 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -21,3 +21,12 @@ if(TARGET plot::imgui) Threads::Threads) plot_apply_warnings(plot_imgui_demo) 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() diff --git a/examples/qt_demo.cpp b/examples/qt_demo.cpp new file mode 100644 index 0000000..363efaa --- /dev/null +++ b/examples/qt_demo.cpp @@ -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 +// macros exist (see the include-order note there). +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +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(i) * kDx; + f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t); + + u32 h = static_cast(i) * 2654435761u; + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + y += kNoiseAmp * (static_cast(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(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(clock::now() - start).count(); + const u64 due = static_cast(elapsed / kDx) + 1; + if (due > next) { + u64 n = due - next; + if (n > kMaxBatch) n = kMaxBatch; + batch_.resize(static_cast(n)); + for (u64 k = 0; k < n; ++k) { + batch_[static_cast(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(next + kBatch) * kDx; + std::this_thread::sleep_until( + start + std::chrono::duration_cast(std::chrono::duration(wake_s))); + } + } + + plot::data::StreamSeries* series_; + std::vector batch_; + std::atomic produced_{0}; + std::atomic 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(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(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(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(widget->mean_frame_ms()), + static_cast(widget->devicePixelRatioF()), + static_cast(viewport.w), static_cast(viewport.h), + static_cast(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(widget->devicePixelRatioF()), logical_before.width(), + logical_before.height(), widget->width(), widget->height(), + static_cast(widget->last_device_viewport().w), + static_cast(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(image.devicePixelRatio())); + std::printf("[qt_demo] layout axes before %.0fx%.0f at (%.0f, %.0f), after %.0fx%.0f at" + " (%.0f, %.0f)\n", + static_cast(before.axes.w), static_cast(before.axes.h), + static_cast(before.axes.x), static_cast(before.axes.y), + static_cast(after.axes.w), static_cast(after.axes.h), + static_cast(after.axes.x), static_cast(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(widget->devicePixelRatioF()); + const auto to_logical = [check_dpr](f32 device_x, f32 device_y) { + return QPointF(static_cast(device_x) / check_dpr, + static_cast(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(box.x)); + const f64 box_expect_max = x_before.to_data_x(static_cast(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(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(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(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(check_axes.w), static_cast(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(stats.visible_samples) / static_cast(axes.w) : 0.0; + const f64 dense = renderer != nullptr ? static_cast(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(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(widget->frames_painted()), + static_cast(meter.frames), meter.elapsed, + static_cast(mean_ms), mean_ms > 0.0f ? 1.0e3 / static_cast(mean_ms) : 0.0, + static_cast(widget->last_frame_ms())); + std::printf("[qt_demo] retained %llu samples, x [%.6f, %.6f] s\n", + static_cast(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(stats.visible_samples), + static_cast(axes.w), static_cast(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; +}