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.
694 lines
32 KiB
C++
694 lines
32 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;
|
|
/// The exit path's verdicts, carried out of the run so `main` can return them: whether the
|
|
/// scripted resize produced a recomputed layout, and whether the screenshot the headless
|
|
/// mode exists for actually reached the disk. Both are properties a caller checks with the
|
|
/// exit status, so neither may live only in stdout.
|
|
bool layout_recomputed = false;
|
|
bool screenshot_written = true; ///< false only when a dump was asked for and failed
|
|
|
|
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));
|
|
demo.screenshot_written = written;
|
|
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;
|
|
// The verdict is carried out of the run, not only printed: a headless caller reads the
|
|
// exit status, and a check whose only output is a line of stdout is a check that passes
|
|
// whatever it finds. A run that cannot resize at all (a window manager or an X server
|
|
// that clamps the window to the screen) fails here, which is the honest answer -- the
|
|
// layout this run is supposed to prove is the layout it never got.
|
|
demo.layout_recomputed = recomputed;
|
|
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});
|
|
// 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.
|
|
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);
|
|
if (app_result != 0) return app_result;
|
|
if (!demo.screenshot_written) {
|
|
std::fprintf(stderr, "plot_gtk_demo: the screenshot could not be written\n");
|
|
return 1;
|
|
}
|
|
if (demo.automated && !demo.layout_recomputed) {
|
|
std::fprintf(stderr,
|
|
"plot_gtk_demo: the scripted resize did not produce a recomputed layout (see the"
|
|
" 'exit frame' lines above; the window must be able to reach %dx%d on this"
|
|
" screen)\n",
|
|
kExitWidth, kExitHeight);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|