// 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; }