667 lines
31 KiB
C++
667 lines
31 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 measured from the labels' font metrics -- a logical size, converted to
|
|
// device pixels at *this* display's scale (see `Style::ui_scale`) -- so at a
|
|
// constant scale 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;
|
|
}
|