Files
plotter/examples/qt_demo.cpp
T
Martino Ferrari daff60e3f0 fix(input,examples): a linked figure's wheel anchor uses the group's live edge, and every demo verdict reaches its exit status
Review fix pass 1 on the task 7 work; four findings, all closed.

1. The Qt demo's region sat across the middle of the window, which is exactly
   where its own scripted input check pans: after the check's box zoom the press
   opened DragMode::MoveRegion, the x range never moved, and the check printed
   'input check pan FAIL' -- under an exit 0, because the verdict was printed and
   not returned. The region now rides the left sixth (as the GLFW demo's does),
   both checks pass, and the verdict is wired to the exit status. The same audit
   found two more printed-only verdicts -- the GTK demo's scripted-resize layout
   check and its screenshot write (and Qt's) -- now wired too. What stays
   printed-only is the screenshot line of the GLFW host and the ImGui widget,
   where the dump happens inside the adapter's frame loop.

2. A follow-ing link group was invisible to the input layer: a member's own axis
   must be XFollow::Off (link_x() refuses a Live member), so Controller's
   x_zoom_anchor() extrapolated a coordinate from the pointer instead of using
   the live edge and a wheel over a row's axis strip walked the window back by
   19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its
   own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written
   by Figure::push_link, cleared when the group stops following or the row
   unlinks), and x_zoom_anchor() reads it. Public-header addition, called out.
   Measured after: behind 0.012992 s and the window's right edge bit-identical to
   the edge the group published at the gesture.

3. The multiplot summary printed x_link_count() -- a number of groups -- under
   'rows in it'. It now reports the members and the group count separately, and
   every row line carries its own group id.

4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest
   row and only clamped below the last one, so a gap pixel went to row 0. It now
   selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap --
   the first coverage of the host's figure routing, which needs no window.

New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle),
tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229
cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking
modes exit 0, and the amended assertions were confirmed failing against the
pre-fix behaviour.
2026-09-28 15:52:08 +02:00

618 lines
30 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 scripted input check's verdict, read after `exec()`: a FAIL that is only printed
/// is a check no caller can act on -- the exit status is what a headless run reads. Same
/// for the exit frame's screenshot: the headless mode exists to produce that file.
bool inputs_ok = true;
bool shot_ok = true;
// 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());
// The left sixth of the window, not its middle: a region is a grabbable handle, so
// one laid across the centre turns the pan this demo's scripted input check makes
// (and the pan a user makes) into a region drag -- the same trap the GLFW demo hit.
// The cursors are not within a grab radius of the centre either, which is what
// keeps the check's press a pan.
p.set_region(region, xr.min + 0.06 * xr.span(), xr.min + 0.18 * 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);
shot_ok = written;
std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED");
if (!written) std::fprintf(stderr, "plot_qt_demo: the screenshot could not be written\n");
}
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);
inputs_ok = box_ok && drag_ok;
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);
if (!inputs_ok) {
std::fprintf(stderr,
"plot_qt_demo: the scripted input check failed (see the 'input check' lines"
" above)\n");
}
// 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.
if (app_result != 0) return app_result;
return inputs_ok && shot_ok ? 0 : 1;
}