Files
plotter/examples/gtk_demo.cpp
T
Martino Ferrari 377cca8e9b fix(gtk_demo): assert the recompute invariant, not a strip width; make exit idempotent
The exit-frame self-check compared the y label strip to the pixel and printed a
FAIL at GDK_SCALE=2 where it measured 174 -> 172. Measured (temporary probe,
reverted): the strip is `widest major tick label + padding`, and the tick step
follows the axes length, which the resize changes -- 18 majors over 1440 device px
with "-800m" widest, 8 over 1080 with "-500m", 78 vs 76 px in the bitmap font. The
layout was recomputed; the equality was the wrong assertion. The check now asserts
what actually distinguishes a recompute from a scaled stale layout: the axes and
its strips tile the *current* viewport, and the strip is nearer the previous strip
than that strip scaled by the resize ratio (2 px of drift against a scaled 118).

`on_exit_timer`'s main-context pump can re-enter its own 20 ms timeout, which ran
the exit block twice in one observed run; `on_teardown` is guarded the same way so
the producer join, the window destruction and the quit happen exactly once. A window
closed by hand takes the same ordering through `GtkWindow::close-request`, which
`gtk_window_destroy()` does not emit.
2026-09-28 03:04:15 +02:00

665 lines
30 KiB
C++

// The GTK4 application: the same 1 MS/s stream the other demos draw, as the
// content of a GtkApplicationWindow whose header bar carries its own "Fit"
// button, with GTK drawing the window chrome around a plot the area renders into
// an FBO of its own.
//
// What it demonstrates:
//
// - `plot::gtk4::PlotArea`: a GtkGLArea (GTK's own framebuffer, no texture, no
// offscreen pass of this project's) driven by GTK's gestures and event
// controllers -- drag to pan, shift+drag to box zoom, ctrl+drag to move a
// cursor, middle-drag on a label strip to zoom one axis, wheel to zoom about
// the pointer, `+` / `-` / `f` on the keyboard;
// - the device-pixel mapping: GTK allocates and reports in logical pixels, the
// area's framebuffer is in device pixels, and the area converts both the
// viewport and every input coordinate with the same factor (see the header);
// - the demo-side controls the area knows nothing about -- the header bar's "Fit";
// - a scripted resize before the exit frame, so the headless run proves the area
// 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 GL
// area's *own framebuffer*, read back by `dump_area_ppm` in the render
// callback, so the PPM holds the plot surface: the header bar and the window
// frame are GTK's and are not in that framebuffer. (GTK composites the area's
// FBO into the window as a texture, so a whole-window shot would need GTK's
// snapshot machinery; this one is honest about what it shows.)
//
// The producer is joined before the stream it writes into is destroyed, and
// before the area's GL resources are released with the widget.
// This library's GL surface first, then GTK -- the order every adapter here keeps.
// GTK's headers pull in no `<GL/gl.h>`, so unlike Qt there is no macro to dodge.
#include <gtk4/gtk_plot_area.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/gl/api.hpp>
#include <plot/plot/ticks.hpp>
#include <gtk/gtk.h>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <numbers>
#include <thread>
#include <utility>
#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);
/// Logical pixels. The area's framebuffer -- the area's GL viewport -- is in
/// device pixels.
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 staged frame)
};
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 the GL area's own framebuffer out as a binary PPM (P6, 8 bits per
/// channel, rows top-down).
///
/// Demo-side, not library API -- hence the name: this is the smoke mode's picture
/// of the *area's* framebuffer, which is what GTK composites into the window.
/// `glReadPixels`'s origin is the framebuffer's bottom-left and a PPM's is its
/// top-left, so the rows are written in reverse; the alpha channel is dropped,
/// and the plot clears its whole viewport to an opaque background, so the RGB is
/// final. An `GL_RGBA` row is `width * 4` bytes -- a multiple of four -- so the
/// default `GL_PACK_ALIGNMENT` holds and no pixel-store state has to be touched.
bool dump_area_ppm(const char* path, int w, int h) {
if (path == nullptr || path[0] == '\0' || w <= 0 || h <= 0) return false;
const plot::usize width = static_cast<plot::usize>(w);
const plot::usize height = static_cast<plot::usize>(h);
std::vector<plot::u8> pixels(width * height * 4u);
plot::gl::glReadPixels(0, 0, w, h, plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE,
pixels.data());
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) return false;
if (std::fprintf(file, "P6\n%d %d\n255\n", w, h) < 0) {
std::fclose(file);
return false;
}
const plot::usize row_bytes = width * 3u;
std::vector<plot::u8> row(row_bytes);
bool ok = true;
for (plot::usize y = height; y-- > 0u && ok;) {
const plot::u8* src = pixels.data() + y * width * 4u;
for (plot::usize x = 0; x < width; ++x) {
row[x * 3u + 0u] = src[x * 4u + 0u];
row[x * 3u + 1u] = src[x * 4u + 1u];
row[x * 3u + 2u] = src[x * 4u + 2u];
}
ok = std::fwrite(row.data(), 1u, row_bytes, file) == row_bytes;
}
if (!ok) {
std::fclose(file);
return false;
}
return std::fclose(file) == 0;
}
/// The run summary's numbers, taken while the widget -- and with it the area and
/// its renderer -- is still alive. GTK frees the wrapper when the widget is
/// finalized, so the summary prints this instead of reading through a pointer
/// that may already be gone.
struct Snapshot {
bool have_renderer = false;
plot::ErrorCode init_code = plot::ErrorCode::Ok;
const char* init_message = "";
u64 frames = 0;
f32 mean_ms = 0.0f;
f32 last_ms = 0.0f;
plot::FrameStats stats{};
plot::Rect axes{};
f32 dense_spp = 0.0f;
bool persistent = false;
bool gl_error = false;
};
/// Everything the demo owns, reachable from the signal callbacks and from the
/// exit path.
struct Demo {
plot::data::StreamSeries* stream = nullptr;
Producer* producer = nullptr;
plot::Plot* graph = nullptr;
plot::gtk4::PlotArea* area = nullptr; ///< null once GTK has destroyed the widget
GtkWindow* window = nullptr;
GtkApplication* app = nullptr;
u32 axis_x = 0;
u32 axis_y = 0;
u32 cursor_a = 0;
u32 cursor_b = 0;
u32 measure = 0;
/// A weak pointer on the area's widget: GTK frees the wrapper with it, so this
/// is how the callbacks that outlive the window (the pump, the summary) know
/// whether the widget can still be touched.
GtkWidget* widget = nullptr;
bool automated = false;
f64 exit_after_s = 0.0;
f64 run_start = 0.0;
const char* screenshot = nullptr;
/// Set by the exit path, read by the after-render handler that takes the
/// screenshot and ends the run. Non-null means "this render is the exit frame".
const char* dump_path = nullptr;
bool exit_pending = false;
bool summary_printed = false;
/// The two exit sequences, each guarded so it runs exactly once: the timer's
/// block (which pumps the main context and can be re-entered by its own timeout)
/// and the teardown (scheduled as an idle source).
bool exit_started = false;
bool torn_down = false;
bool cursors_follow = true;
Meter meter;
Snapshot snapshot;
};
void capture_snapshot(Demo& demo) {
Snapshot& s = demo.snapshot;
s.init_code = demo.area->init_status().code;
s.init_message = demo.area->init_status().message;
plot::Renderer* const renderer = demo.area->renderer();
s.have_renderer = renderer != nullptr;
s.frames = demo.area->frames_painted();
s.mean_ms = demo.area->mean_frame_ms();
s.last_ms = demo.area->last_frame_ms();
s.axes = std::as_const(*demo.graph).layout().axes;
if (renderer != nullptr) {
s.stats = renderer->stats();
s.dense_spp = renderer->style().dense_threshold_spp;
s.persistent = renderer->using_persistent_upload();
s.gl_error = renderer->last_frame_had_gl_error();
}
}
/// A window closed by hand -- or by the window manager -- takes the ordering the
/// automated path takes: the producer is joined *before* GTK destroys the widget,
/// and with it the renderer whose frames read the stream. GTK emits this before its
/// own handler destroys the window, and returning FALSE lets that default happen.
gboolean on_close_request(GtkWindow*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.producer->stop();
return FALSE;
}
/// Runs the window's destruction and the quit outside any signal emission: see
/// `on_render_after`.
gboolean on_teardown(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
// The teardown runs once, whatever scheduled it (the exit frame's idle source, or
// a second one if two renders were pending): the window is destroyed and the
// application quit here, and both of those must not happen twice.
if (demo.torn_down) return G_SOURCE_REMOVE;
demo.torn_down = true;
// The producer is joined *first*, while the widget and the stream it feeds are
// both still alive: the join is what makes "no thread pushes into the stream
// after this point" true for the destruction below, and for the frames the
// widget may still stage while it is being torn down.
demo.producer->stop();
// Destroyed explicitly rather than left to the application's shutdown, so an
// automated run exercises the widget's teardown -- the renderer's GL objects are
// released in `::unrealize`, with the context still current -- and a fault in it
// shows up in the run's exit status rather than after the output has been read.
gtk_window_destroy(demo.window);
g_application_quit(G_APPLICATION(demo.app));
return G_SOURCE_REMOVE;
}
/// The repaint pump. GTK only redraws the area when something asks it to, so this
/// is the frame rate the demo runs at (a real application drives the same timer,
/// or its toolkit's equivalent, from whatever it animates). Nothing here is
/// library API: `gtk_gl_area_queue_render` is GTK's.
gboolean on_repaint(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (demo.widget == nullptr) { // the window was closed: the wrapper is gone too
demo.area = nullptr;
return G_SOURCE_REMOVE;
}
capture_snapshot(demo);
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
return G_SOURCE_CONTINUE;
}
/// Connected with `g_signal_connect_after`, so it runs once the adapter's own
/// handler has staged the frame and the area's FBO holds it. It is also the one
/// place that sees a render after a failed init -- the adapter's handler returns
/// early then, but GTK still emits the signal -- which keeps the snapshot's
/// status meaningful.
gboolean on_render_after(GtkGLArea*, GdkGLContext*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (demo.area != nullptr) capture_snapshot(demo);
if (!demo.exit_pending) return FALSE;
demo.exit_pending = false;
if (demo.dump_path != nullptr && demo.dump_path[0] != '\0') {
const plot::Rect viewport = demo.area->device_viewport();
const bool written = dump_area_ppm(demo.dump_path, static_cast<int>(viewport.w),
static_cast<int>(viewport.h));
std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path,
static_cast<int>(viewport.w), static_cast<int>(viewport.h),
written ? "written" : "FAILED");
std::fflush(stdout);
}
// The teardown is *deferred*: closing the window destroys this very widget, and
// the render signal is still being emitted, so doing it on this stack would free
// the area GTK is in the middle of calling back into. An idle source runs on the
// next main-loop iteration, after the emission has returned.
g_idle_add(&on_teardown, &demo);
return FALSE;
}
/// The header bar's "Fit": one-shot framing of the data. `auto_fit_*` re-arms
/// auto scale, which the frame callback then follows, so this is also how a pan
/// is undone.
void on_fit_clicked(GtkButton*, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.graph->auto_fit_x();
demo.graph->auto_fit_y(demo.axis_y);
// A click moves focus to the button, which would take the controller's keys away
// from the plot; hand it back, or `+`/`-`/`f` would stop reaching the plot.
gtk_widget_grab_focus(demo.area->widget());
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
}
/// The headless exit path: at the deadline, resize the window (scripted), check
/// that the layout was recomputed for the new geometry, then queue the exit frame
/// and let `on_render_after` take the screenshot and end the run.
gboolean on_exit_timer(gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
if (!demo.automated || now_s() - demo.run_start < demo.exit_after_s) return G_SOURCE_CONTINUE;
// The pump below iterates the main context, so this very timeout can fire from
// inside this call; the guard is what keeps the exit sequence -- the scripted
// resize, its check, the exit frame -- to one run.
if (demo.exit_started) return G_SOURCE_REMOVE;
demo.exit_started = true;
if (demo.widget == nullptr) return G_SOURCE_REMOVE; // the window is already gone
const plot::PlotLayout before = std::as_const(*demo.graph).layout();
const plot::Rect viewport_before = demo.area->device_viewport();
const int width_before = gtk_widget_get_width(demo.area->widget());
const int height_before = gtk_widget_get_height(demo.area->widget());
// The resize goes through the window system, so pump the main context until the
// area's allocation follows, with a deadline. The pump is what lets the resize
// and the frames in between (which re-derive the viewport) actually happen.
gtk_window_set_default_size(demo.window, kExitWidth, kExitHeight);
const f64 deadline = now_s() + 1.0;
while (gtk_widget_get_width(demo.area->widget()) == width_before && now_s() < deadline) {
while (g_main_context_pending(nullptr)) g_main_context_iteration(nullptr, FALSE);
g_usleep(1000);
}
const plot::PlotLayout after = std::as_const(*demo.graph).layout();
const plot::Rect viewport = demo.area->device_viewport();
// The evidence that the layout was *recomputed and not scaled*: the y label
// strip is a number of *font pixels*, so it stays the same size while the axes takes
// the rest of the new allocation. Two things are checked, and neither is "the
// strip is the same number":
//
// * the axes and its strips tile the *current* viewport exactly -- a layout
// left over from the old geometry would still tile the old one;
// * the strip is nearer the previous strip than that strip scaled by the resize
// ratio -- a layout whose strips were *scaled* would land on the second.
//
// The strip is not invariant, and the check must not pretend it is: it is the
// widest major tick label plus padding, and the tick *step* follows the axes
// length, which the resize changes. Measured at scale 2: 18 majors over 1440
// device px with "-800m" the widest label, 8 over 1080 with "-500m" -- 78 and
// 76 px in this bitmap font, whose '8' is the wider glyph. That is 2 px of
// recomputed strip, against 174 * 1620/2380 = 118 px of scaled strip.
const f32 resize_ratio = viewport_before.w > 0.0f ? viewport.w / viewport_before.w : 1.0f;
const f32 scaled_strip = before.axes.x * resize_ratio;
const f32 drift = std::fabs(after.axes.x - before.axes.x);
const bool tiles = std::fabs(after.axes.w - (viewport.w - after.axes.x)) <= 0.5f &&
std::fabs(after.axes.h - (viewport.h - after.axes.y - after.x_axis_height -
after.x_title_height)) <= 0.5f;
const bool metric_strip = drift < std::fabs(after.axes.x - scaled_strip);
const bool recomputed = after.axes.w != before.axes.w && tiles && metric_strip;
std::printf("\n[gtk_demo] ---- exit frame ----\n");
std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n",
width_before, height_before, gtk_widget_get_width(demo.area->widget()),
gtk_widget_get_height(demo.area->widget()), static_cast<unsigned>(viewport.w),
static_cast<unsigned>(viewport.h), static_cast<double>(demo.area->device_scale()));
std::printf("[gtk_demo] layout axes %.0fx%.0f at (%.0f,%.0f) -> %.0fx%.0f at (%.0f,%.0f): %s\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),
recomputed ? "recomputed for the new viewport (strips metric, not scaled)"
: "FAIL: stale or rescaled layout");
std::printf("[gtk_demo] layout y label strip %.0f -> %.0f px of a %.0f -> %.0f px wide axes,"
" %.0f px of drift against a scaled %.0f\n",
static_cast<double>(before.axes.x), static_cast<double>(after.axes.x),
static_cast<double>(before.axes.w), static_cast<double>(after.axes.w),
static_cast<double>(drift), static_cast<double>(scaled_strip));
std::printf("[gtk_demo] init %s: %s\n", plot::to_string(demo.area->init_status().code),
demo.area->init_status().message);
std::fflush(stdout);
demo.dump_path = demo.screenshot;
demo.exit_pending = true;
gtk_gl_area_queue_render(GTK_GL_AREA(demo.area->widget()));
return G_SOURCE_REMOVE;
}
void on_activate(GtkApplication* app, gpointer user_data) {
Demo& demo = *static_cast<Demo*>(user_data);
demo.app = app;
GtkWidget* window = gtk_application_window_new(app);
demo.window = GTK_WINDOW(window);
gtk_window_set_title(GTK_WINDOW(window), "plot gtk4 demo");
gtk_window_set_default_size(GTK_WINDOW(window), kWindowWidth, kWindowHeight);
// The hand-close path's half of the shutdown ordering (see `on_close_request`);
// the automated path stops the producer in `on_teardown` instead, because
// `gtk_window_destroy()` does not emit this signal.
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &demo);
// The plot area is the window's content. GTK owns the widget from here on: the
// wrapper is freed with it, which is also what releases the plot's GL resources.
demo.area = plot::gtk4::PlotArea::create(*demo.graph);
gtk_window_set_child(GTK_WINDOW(window), demo.area->widget());
// The weak pointer has to be seeded with the widget it watches: GLib only
// remembers the *address* and clears it at finalization, it does not read or
// store the object (this is why the demo's copy is NULL until the two lines
// below -- and why a missing assignment would leave every callback believing
// the window was already gone).
demo.widget = demo.area->widget();
g_object_add_weak_pointer(G_OBJECT(demo.widget), reinterpret_cast<gpointer*>(&demo.widget));
GtkWidget* header = gtk_header_bar_new();
gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header), gtk_label_new("plot gtk4 demo"));
GtkWidget* fit = gtk_button_new_with_label("Fit");
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &demo);
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), fit);
gtk_window_set_titlebar(GTK_WINDOW(window), header);
// The exit frame's screenshot is taken from the render signal, after the
// adapter's handler has drawn the frame: `_after` is what guarantees the order.
g_signal_connect_after(demo.area->widget(), "render", G_CALLBACK(on_render_after), &demo);
// The frame callback: the same host-side work the other demos do, on the thread
// that owns the GL context (the GTK main thread), never on the producer's.
demo.area->set_frame_callback([&demo](plot::Plot& p, plot::f64 dt) {
demo.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(demo.axis_x).auto_scale) p.auto_fit_x();
if (p.y_axis(demo.axis_y).auto_scale) p.auto_fit_y(demo.axis_y);
if (demo.cursors_follow) {
if (demo.area->controller().drag_mode() == plot::input::DragMode::MoveCursor ||
!p.x_axis(demo.axis_x).auto_scale) {
demo.cursors_follow = false;
} else {
const plot::Range xr = p.x_range(demo.axis_x);
p.set_cursor_x(demo.cursor_a, xr.min + 0.35 * xr.span());
p.set_cursor_x(demo.cursor_b, xr.min + 0.65 * xr.span());
}
}
demo.meter.frames += 1;
demo.meter.elapsed += dt;
if (demo.meter.elapsed - demo.meter.last_report >= 0.5) {
const u64 produced = demo.producer->produced();
demo.meter.rate = static_cast<f64>(produced - demo.meter.last_produced) /
(demo.meter.elapsed - demo.meter.last_report);
demo.meter.last_produced = produced;
demo.meter.last_report = demo.meter.elapsed;
const plot::Rect viewport = demo.area->last_device_viewport();
const plot::Rect axes = std::as_const(p).layout().axes;
const plot::Range xr = p.x_range(demo.axis_x);
std::printf(
"[gtk_demo] t=%.1fs %.3f MS/s %.2f ms/frame scale %g fbo %ux%u axes %.0fx%.0f"
" x [%.6f, %.6f] span %.6f auto %d %llu frames\n",
demo.meter.elapsed, demo.meter.rate * 1.0e-6,
static_cast<double>(demo.area->mean_frame_ms()),
static_cast<double>(demo.area->device_scale()), static_cast<unsigned>(viewport.w),
static_cast<unsigned>(viewport.h),
static_cast<double>(axes.w), static_cast<double>(axes.h), xr.min, xr.max, xr.span(),
p.x_axis(demo.axis_x).auto_scale ? 1 : 0,
static_cast<unsigned long long>(demo.meter.frames));
std::fflush(stdout);
}
});
gtk_window_present(GTK_WINDOW(window));
// So the controller's keys reach the plot: a key event goes to the focused
// widget, and a `GtkGLArea` is focusable because the adapter says so.
gtk_window_set_focus(GTK_WINDOW(window), demo.area->widget());
// The repaint pump (~60 Hz, the display rate this demo runs at) and -- in the
// automated mode -- the deadline that runs the exit path.
g_timeout_add(16, &on_repaint, &demo);
if (demo.automated) g_timeout_add(20, &on_exit_timer, &demo);
}
/// The run summary, printed after the main loop: the stream, the plot and the
/// producer are the demo's, and outlive the window, so only the renderer's
/// numbers come from the snapshot.
void print_summary(Demo& demo) {
const Snapshot& s = demo.snapshot;
const plot::Measurement m = demo.graph->measurement(demo.measure);
const f64 spp = s.axes.w > 0.0f ? static_cast<f64>(s.stats.visible_samples) /
static_cast<f64>(s.axes.w)
: 0.0;
std::printf("\n[gtk_demo] ---- run summary ----\n");
std::printf("[gtk_demo] producer %llu samples pushed, last metered %.3f MS/s\n",
static_cast<unsigned long long>(demo.producer->produced()), demo.meter.rate * 1.0e-6);
std::printf("[gtk_demo] frames %llu staged (%llu frame callbacks) in %.3f s, mean %.2f"
" ms/frame (%.1f fps), last %.2f ms\n",
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(demo.meter.frames), demo.meter.elapsed,
static_cast<double>(s.mean_ms),
s.mean_ms > 0.0f ? 1.0e3 / static_cast<double>(s.mean_ms) : 0.0,
static_cast<double>(s.last_ms));
std::printf("[gtk_demo] retained %llu samples, x [%.6f, %.6f] s\n",
static_cast<unsigned long long>(demo.stream->size()),
demo.stream->x_bounds().min, demo.stream->x_bounds().max);
std::printf("[gtk_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s, visible %llu,"
" axes %.0fx%.0f, %u segments, %u draw calls\n",
spp, static_cast<double>(s.dense_spp),
s.stats.lod_traces > 0 ? "LOD pyramid" : "raw samples",
static_cast<unsigned long long>(s.stats.visible_samples),
static_cast<double>(s.axes.w), static_cast<double>(s.axes.h), s.stats.staged_segments,
s.stats.draw_calls);
std::printf("[gtk_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("[gtk_demo] gl %s upload, gl error on the last frame %d\n",
s.persistent ? "persistent-mapped ring" : "orphan", s.gl_error ? 1 : 0);
std::printf("[gtk_demo] init %s: %s\n", plot::to_string(s.init_code), s.init_message);
std::fflush(stdout);
}
} // 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");
// --- the stream and the plot the area 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.
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);
Demo demo;
demo.stream = &stream;
demo.producer = &producer;
demo.graph = &graph;
demo.axis_x = axis_x;
demo.axis_y = axis_y;
demo.cursor_a = cursor_a;
demo.cursor_b = cursor_b;
demo.measure = measure;
demo.automated = automated;
demo.exit_after_s = exit_after_s;
demo.run_start = now_s();
demo.screenshot = screenshot;
GtkApplication* app = gtk_application_new("org.plot.gtk_demo", G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), &demo);
const int app_result = g_application_run(G_APPLICATION(app), argc, argv);
// The window is gone by now -- `on_render_after` destroyed it before quitting --
// so only what the demo owns is read below.
producer.stop(); // already stopped by on_teardown; idempotent, and this is the
// path a window closed by hand takes (no on_teardown then)
print_summary(demo);
std::printf("[gtk_demo] teardown the area widget is %s after the main loop\n",
demo.widget != nullptr ? "still alive" : "destroyed (the wrapper and its GL"
" resources went with it)");
g_object_unref(app);
return app_result;
}