Files
plotter/examples/qt_demo.cpp
T
Martino Ferrari 942f88c568 feat(examples): a three-row synced Figure demo, on a GLFW host that draws one
The multi-plot example the phase exists for: three plots stacked in one
Figure, sharing one x link with following armed, each row carrying its own
named signals and its own legend, driven by six StreamSeries at 500 kS/s.
Two cursors and a region with an exact statistics readout reach the direct
manipulation of spec §5 in the same frame, and PLOT_DEMO_SYNC=1 is a
self-checking mode that exits non-zero unless all three rows hold one range
and the shared right edge keeps tracking the newest sample through a zoom, a
pan on another row and more arriving data.

The GLFW host gains the figure mode the demo needs: set_figure() makes the
run loop draw the Figure instead of its own plot and route each pointer,
wheel and key event to the row it lands in, through one Controller per row
(row_controller()). All of it is opt-in; the single-plot path is untouched.
2026-09-28 15:24:22 +02:00

599 lines
29 KiB
C++

// 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)};
/// The region's fill and bound stroke, in colours nothing else here draws, so a pixel
/// count of either is about the region.
constexpr plot::Color kRegionFill = plot::Color::hex(0x33FF00, 0.35f);
constexpr plot::Color kRegionEdge = plot::Color::hex(0xFF00FF);
/// 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});
// Named, so the plot's legend -- on by default -- lists the signal.
const u32 trace = graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y, "signal"});
// 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);
// One region, so the direct manipulation of spec §5 is reachable in this demo too: a
// shaded interval with the statistics readout in the exit frame. Its bounds ride the
// live window until the user drags a handle, exactly as the cursors do.
plot::Region region_desc;
region_desc.snap = plot::RegionSnap::Free;
region_desc.fill = kRegionFill;
region_desc.edge = kRegionEdge;
region_desc.label = "roi";
const u32 region = graph.add_region(region_desc);
// --- 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. The region's bounds ride the same window.
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) {
const plot::input::DragMode mode = widget->controller().drag_mode();
const bool dragging = mode == plot::input::DragMode::MoveCursor ||
mode == plot::input::DragMode::MoveRegion ||
mode == plot::input::DragMode::ResizeRegionLeft ||
mode == plot::input::DragMode::ResizeRegionRight;
if (dragging || !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());
p.set_region(region, xr.min + 0.40 * xr.span(), xr.min + 0.60 * 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));
// The region's last-frame geometry and its exact statistics, so the screenshot's
// pixels can be read against the rects the layout measured (`Series::reduce/sum/sumsq`
// over the interval, never the pyramid's bucket forms).
for (const plot::RegionLayout& rl : after.regions) {
if (rl.region != region) continue;
const plot::RegionStats& rs = graph.region_stats(region, trace);
std::printf("[qt_demo] region %u: fill x %.1f y %.1f w %.1f h %.1f, bounds at %.1f and"
" %.1f\n",
region, rl.fill.x, rl.fill.y, rl.fill.w, rl.fill.h,
static_cast<plot::f64>(plot::handle_column(rl.left_handle)),
static_cast<plot::f64>(plot::handle_column(rl.right_handle)));
if (rs.valid) {
std::printf("[qt_demo] region %u: x [%g, %g] s, n %llu, mean %g, rms %g, min %g, max"
" %g, integral %g\n",
region, rs.x0, rs.x1, static_cast<unsigned long long>(rs.count), rs.mean,
rs.rms, rs.min, rs.max, rs.integral);
}
}
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;
}