The factor both the viewport and every forwarded input coordinate use came from `gtk_widget_get_scale_factor()`, which GTK documents as "the next higher integer value" under fractional scaling. It is now derived by `DeviceMapping::from_sizes()` from the widget's logical allocation and the device size `GtkGLArea::resize` reports -- the viewport that signal's default handler sets with `glViewport(0, 0, w, h)` -- so the viewport, the renderer and input are one value by construction. The public `int scale_factor()` became `f32 device_scale()`. `kSurfacePixelsPerNotch` is now 100.0 and documented as a *calibration* with a stated assumption, not a unit conversion attributed to axis_value120/Qt. `::unrealize` releases the renderer unconditionally, after making the context current: the error it used to check says nothing about whether GL names exist (creation failure is the path that returns before a renderer is built), and skipping the release left the deletes to the wrapper's destructor, with no context. The drag anchor caches its press point and offsets in logical pixels and scales the difference by the factor in force now, so a scale change mid-drag cannot become a phantom pan. The demo stops its producer in `on_teardown`, before destroying the window whose renderer the stream feeds.
613 lines
27 KiB
C++
613 lines
27 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;
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// 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 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;
|
|
}
|
|
|
|
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;
|
|
if (demo.widget == nullptr) return G_SOURCE_REMOVE; // the window is already gone
|
|
|
|
const plot::PlotLayout before = std::as_const(*demo.graph).layout();
|
|
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 derived from the font metrics, so it keeps its width while the axes
|
|
// take the rest of the new allocation. A scaled stale layout would have scaled
|
|
// the strip with it.
|
|
const bool recomputed = after.axes.w != before.axes.w && after.axes.x == before.axes.x;
|
|
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 (label strip unchanged)" : "FAIL: stale or rescaled layout");
|
|
std::printf("[gtk_demo] layout y label strip %.0f -> %.0f px of a %.0f -> %.0f px wide axes\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));
|
|
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 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;
|
|
}
|