diff --git a/CMakeLists.txt b/CMakeLists.txt index be7fb03..7a7b303 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,10 @@ if(PLOT_BUILD_QT6) add_subdirectory(adapters/qt6) endif() +if(PLOT_BUILD_GTK4) + add_subdirectory(adapters/gtk4) +endif() + if(PLOT_BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/CMakePresets.json b/CMakePresets.json index e0b70db..3803362 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -16,7 +16,8 @@ "PLOT_BUILD_GLFW": true, "PLOT_BUILD_IMGUI": true, "PLOT_BUILD_EXAMPLES": true, - "PLOT_BUILD_QT6": true + "PLOT_BUILD_QT6": true, + "PLOT_BUILD_GTK4": true } }, { @@ -29,7 +30,8 @@ "PLOT_BUILD_GLFW": true, "PLOT_BUILD_IMGUI": true, "PLOT_BUILD_EXAMPLES": true, - "PLOT_BUILD_QT6": true + "PLOT_BUILD_QT6": true, + "PLOT_BUILD_GTK4": true } } ], diff --git a/adapters/gtk4/CMakeLists.txt b/adapters/gtk4/CMakeLists.txt new file mode 100644 index 0000000..8f19e9d --- /dev/null +++ b/adapters/gtk4/CMakeLists.txt @@ -0,0 +1,34 @@ +# The GTK4 adapter: a GtkGLArea whose own framebuffer the plot is rendered into +# and driven from GTK's gestures and event controllers. GTK is a C library whose +# GL widget cannot be subclassed from C++ (`G_DEFINE_TYPE` declares the type's +# instance and class structs), so the adapter is a factory that builds an instance +# and wraps it -- the shape the header documents. +# +# Unlike GLFW and ImGui -- and like Qt -- this is not a fetched dependency: GTK4 +# is a system SDK, and a project that enables this target is asking for the +# installed one. Discovery is pkg-config, and a missing GTK4 fails configuration +# with the fix in the message. +find_package(PkgConfig QUIET) +if(PkgConfig_FOUND) + pkg_check_modules(GTK4_PC QUIET IMPORTED_TARGET gtk4) +endif() + +if(NOT TARGET PkgConfig::GTK4_PC) + message(FATAL_ERROR + "plot: PLOT_BUILD_GTK4 is ON but GTK4 was not found (pkg-config gtk4). Install the " + "GTK4 development package -- on Arch, gtk4 -- or configure with -DPLOT_BUILD_GTK4=OFF, " + "or point PKG_CONFIG_PATH at the GTK4 .pc file.") +endif() + +add_library(plot_gtk4 STATIC gtk_plot_area.cpp gtk_plot_area.hpp gtk_input.hpp) +add_library(plot::gtk4 ALIAS plot_gtk4) +# The public include root is `adapters/`, so the headers are reached as +# and -- the toolkit's name first, +# like every other adapter's. +target_include_directories(plot_gtk4 PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# GTK is PUBLIC, unlike GLFW and ImGui: the adapter's factory hands out a +# GtkWidget, so a consumer that embeds the area needs GTK's headers and libraries +# to put it anywhere -- the widget *is* the toolkit's object -- and GTK's include +# directory is not one the compiler searches by default. +target_link_libraries(plot_gtk4 PUBLIC plot::core PkgConfig::GTK4_PC) +plot_apply_warnings(plot_gtk4) diff --git a/adapters/gtk4/gtk_input.hpp b/adapters/gtk4/gtk_input.hpp new file mode 100644 index 0000000..6b5efcd --- /dev/null +++ b/adapters/gtk4/gtk_input.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include + +namespace plot::gtk4 { + +/// GDK's scroll unit (`GdkScrollUnit`), mirrored here so this header -- and a +/// test that drives it -- needs no GTK include directory. `gtk_plot_area.cpp` +/// static-asserts that the two agree with GDK's own enumerators, so a GDK that +/// renumbered them would fail to compile rather than silently mis-scale a zoom. +enum class ScrollUnit { + Wheel = 0, ///< the delta is already in wheel detents, one per notch + Surface = 1, ///< the delta is in surface logical pixels +}; + +/// Surface logical pixels per wheel notch, for a `ScrollUnit::Surface` delta. +/// +/// GDK reports a smooth device's scroll in surface pixels, the controller takes +/// notches, and passing a pixel delta through would make a touchpad's zoom +/// unusably sensitive -- a flick is hundreds of pixels, and the controller's one +/// notch is ~14% of the span. 120 pixels per notch is the model Wayland's +/// `wl_pointer.axis_value120` introduced ("the value 120 corresponds to one +/// physical scroll click/detent"), and it is the same 120-per-notch scale a Qt +/// `QWheelEvent::angleDelta()` uses (eighths of a degree, 120 of them to a +/// notch), so a smooth device zooms at the detent rate its discrete siblings do +/// rather than an order of magnitude faster. A delta already reported in wheel +/// units is never divided by this. +inline constexpr f64 kSurfacePixelsPerNotch = 120.0; + +/// GTK's `GdkModifierType` bits as the controller's `input::Mod` mask. +/// +/// A mask, not a reinterpretation: GDK's state carries the *mouse button* bits +/// as well (`GDK_BUTTON1_MASK` is `1 << 8`, and a release reports the button that +/// produced it), so the four modifiers the controller names are the only ones +/// that survive. Without the mask a right-drag would arrive at the controller +/// with an extra `0x400` bit that no other adapter's event carries. +inline u32 mods_from(u32 gdk_state) noexcept { + // The GDK bit values are repeated rather than included: this header names no + // GDK type, and the constants are fixed by GDK's public ABI. `gtk_plot_area.cpp` + // static-asserts them against GDK's own enumerators. + constexpr u32 kGdkShift = 1u << 0; + constexpr u32 kGdkControl = 1u << 2; + constexpr u32 kGdkAlt = 1u << 3; + constexpr u32 kGdkSuper = 1u << 26; + constexpr u32 kGdkMeta = 1u << 28; // maps to Command on macOS, Super here + + u32 out = 0; + if ((gdk_state & kGdkShift) != 0u) out |= static_cast(input::Mod::Shift); + if ((gdk_state & kGdkControl) != 0u) out |= static_cast(input::Mod::Ctrl); + if ((gdk_state & kGdkAlt) != 0u) out |= static_cast(input::Mod::Alt); + if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) { + out |= static_cast(input::Mod::Super); + } + return out; +} + +/// The button one of GTK's gestures reports, as the controller's enum. +/// +/// Buttons 1, 2 and 3 are the only ones GTK names (left, middle, right) and the +/// only ones the controller has a gesture for. This adapter listens with +/// `gtk_gesture_single_set_button(gesture, 0)`, which GTK documents as "any +/// button", so a press from button 8 or 9 (the navigation buttons) reaches the +/// handler too: `nullopt` is returned for it, and the event is dropped before it +/// can begin a pan. +inline std::optional button_from(u32 gdk_button) noexcept { + switch (gdk_button) { + case 1u: return input::Button::Left; + case 2u: return input::Button::Middle; + case 3u: return input::Button::Right; + default: return std::nullopt; + } +} + +/// A GDK scroll delta in wheel notches, positive = zoom in. +/// +/// Two conversions in one place, the same two every adapter has to make: +/// +/// * the *unit* -- a `ScrollUnit::Surface` delta is scaled by +/// `1 / kSurfacePixelsPerNotch` (see there); a `ScrollUnit::Wheel` delta is +/// already one unit per detent; +/// * the *sign* -- GDK's positive `dy` is a scroll south, the wheel pushed away +/// from the user, while the controller's positive amount zooms in, so the +/// value is negated. A wheel notch up arrives as `dy == -1.0` and leaves here +/// as `+1.0`. +/// +/// A non-finite delta is returned unchanged: `Controller::on_wheel` refuses it as +/// a strict no-op, which is the one place that decision belongs. +inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept { + if (unit == ScrollUnit::Surface) return -dy / kSurfacePixelsPerNotch; + return -dy; +} + +} // namespace plot::gtk4 diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp new file mode 100644 index 0000000..aa212c2 --- /dev/null +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -0,0 +1,584 @@ +#include + +#include + +#include + +// GTK after this library's GL surface, the order every adapter here keeps. GTK's +// headers pull in no `` -- GdkGLContext is opaque and GtkGLArea names no +// GL type -- so unlike Qt there is no macro to dodge, but the order is the +// project's rule and it keeps the two from ever having to be thought about. +#include + +#include +#include +#include +#include +#include + +// The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that +// renumbered the scroll units or moved a modifier bit fails to compile rather +// than mis-scaling a zoom or dropping a modifier. +static_assert(static_cast(plot::gtk4::ScrollUnit::Wheel) == GDK_SCROLL_UNIT_WHEEL, + "ScrollUnit::Wheel must match GDK_SCROLL_UNIT_WHEEL"); +static_assert(static_cast(plot::gtk4::ScrollUnit::Surface) == GDK_SCROLL_UNIT_SURFACE, + "ScrollUnit::Surface must match GDK_SCROLL_UNIT_SURFACE"); +static_assert((1u << 0) == GDK_SHIFT_MASK, "mods_from's shift bit must match GDK's"); +static_assert((1u << 2) == GDK_CONTROL_MASK, "mods_from's control bit must match GDK's"); +static_assert((1u << 3) == GDK_ALT_MASK, "mods_from's alt bit must match GDK's"); +static_assert((1u << 26) == GDK_SUPER_MASK, "mods_from's super bit must match GDK's"); +static_assert((1u << 28) == GDK_META_MASK, "mods_from's meta bit must match GDK's"); + +namespace plot::gtk4 { +namespace { + +/// The entry points the resolver below could not provide, as passed to +/// `plot::gl::load()`. `load()` may still cover them through its own dlsym +/// fallback, but only a context-aware resolver is guaranteed to return pointers +/// valid for the current context, so a fallback is worth reporting. Process-global +/// and written only from the context's owning thread, which is the only thread +/// `::realize` runs on. +constexpr u32 kMaxUnresolved = 16; +const char* g_unresolved[kMaxUnresolved] = {}; +u32 g_unresolved_count = 0; + +/// `dlsym()` hands back an object pointer for a symbol that is in fact a +/// function. POSIX guarantees the two have the same representation, and the bit +/// copy states that where a `reinterpret_cast` is merely conditionally supported +/// -- the same respelling the GLFW and Qt adapters use for their own resolvers. +template +Fn symbol_as(void* symbol) noexcept { + Fn fn = nullptr; + static_assert(sizeof(fn) == sizeof(symbol), "function and object pointers must match"); + std::memcpy(&fn, &symbol, sizeof(fn)); + return fn; +} + +/// The `*GetProcAddress` of the API that owns GTK's current context, as +/// `plot::gl::ProcLoader`. +/// +/// GDK4 offers no equivalent: `gdk_gl_context_get_proc_address()` was removed from +/// GTK4's public API, and GTK resolves its own GL entry points through libepoxy. +/// The context a `GtkGLArea` gets comes from EGL on both the X11 and the Wayland +/// backend, so `eglGetProcAddress()` is the context-aware resolver for it -- the +/// call every EGL client makes. GLX is tried second, for a GDK built on it. Both +/// are reached with `dlsym(RTLD_DEFAULT, ...)`, which finds them because libgtk +/// links libEGL and libGLX; neither is called when it is not there, in which case +/// the name is reported and `load()`'s own fallback gets its chance. +void* resolve_gl_proc(const char* name) noexcept { + using GetProcAddress = void* (*)(const char*); + static const GetProcAddress egl = + symbol_as(dlsym(RTLD_DEFAULT, "eglGetProcAddress")); + static const GetProcAddress glx = + symbol_as(dlsym(RTLD_DEFAULT, "glXGetProcAddressARB")); + + void* proc = nullptr; + if (egl != nullptr) proc = egl(name); + if (proc == nullptr && glx != nullptr) proc = glx(name); + if (proc == nullptr && g_unresolved_count < kMaxUnresolved) { + g_unresolved[g_unresolved_count++] = name; + } + return proc; +} + +/// Monotonic seconds, for the frame timings only. +f64 now_s() noexcept { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +/// `gtk_event_controller_get_current_event_state()` as the controller's mask. +u32 gesture_mods(GtkEventController* controller) noexcept { + return mods_from(static_cast(gtk_event_controller_get_current_event_state(controller))); +} + +/// The position of the event a controller is handling right now, in device +/// pixels. False when there is no event or it has no position -- a key event with +/// a scroll controller's anchor is the case that matters. +bool current_event_position(GtkEventController* controller, f32 scale, Vec2* out) noexcept { + GdkEvent* event = gtk_event_controller_get_current_event(controller); + if (event == nullptr) return false; + double x = 0.0; + double y = 0.0; + if (!gdk_event_get_position(event, &x, &y)) return false; + *out = Vec2{static_cast(x) * scale, static_cast(y) * scale}; + return true; +} + +/// The widget's allocation in device pixels: what the plot is given, what GTK +/// attaches as the framebuffer, and what `GtkGLArea::resize` reports. Zero-sized +/// before the widget is allocated. +Rect alloc_in_device(GtkWidget* widget) noexcept { + const f32 scale = static_cast(gtk_widget_get_scale_factor(widget)); + return Rect{0.0f, 0.0f, static_cast(gtk_widget_get_width(widget)) * scale, + static_cast(gtk_widget_get_height(widget)) * scale}; +} + +} // namespace + +PlotArea* PlotArea::create(Plot& plot) noexcept { + GtkWidget* widget = gtk_gl_area_new(); + + // The context is asked for at construction: GTK reads these when it creates the + // context at realize, and `gtk_gl_area_set_required_version` is documented to + // have no effect afterwards. 3.3 core is the baseline the library's loader + // resolves; the plot is 2D, so a depth buffer and a stencil buffer would only + // cost memory (GTK's own default is none, stated explicitly so a GTK that + // changed it could not silently add one). + gtk_gl_area_set_required_version(GTK_GL_AREA(widget), 3, 3); + gtk_gl_area_set_has_depth_buffer(GTK_GL_AREA(widget), FALSE); + gtk_gl_area_set_has_stencil_buffer(GTK_GL_AREA(widget), FALSE); + + // A widget that cannot take focus never receives a key event, and the + // controller's `+`, `-` and `f` are key events. + gtk_widget_set_focusable(widget, TRUE); + gtk_widget_set_hexpand(widget, TRUE); + gtk_widget_set_vexpand(widget, TRUE); + + auto* self = new PlotArea(plot, widget); + // The wrapper dies with the widget. `::unrealize` has already released the GL + // objects by then, while the context could still be made current. + g_object_set_data_full(G_OBJECT(widget), "plot-gtk4-area", self, + [](gpointer data) { delete static_cast(data); }); + + g_signal_connect(widget, "realize", G_CALLBACK(&PlotArea::on_realize), self); + g_signal_connect(widget, "unrealize", G_CALLBACK(&PlotArea::on_unrealize), self); + g_signal_connect(widget, "render", G_CALLBACK(&PlotArea::on_render), self); + g_signal_connect(widget, "resize", G_CALLBACK(&PlotArea::on_resize), self); + + // One click gesture and one drag gesture, both listening to every button: the + // plot pans on left *and* right, box-zooms on shift+left, moves a cursor on + // ctrl+left and zooms an axis on middle. GTK cannot be asked for that set + // directly, so the two gestures split it (see the class comment) and the button + // and modifiers decide which gesture the controller runs. + GtkGesture* click = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(click), 0); // 0: any button + g_signal_connect(click, "pressed", G_CALLBACK(&PlotArea::on_click_pressed), self); + g_signal_connect(click, "released", G_CALLBACK(&PlotArea::on_click_released), self); + g_signal_connect(click, "stopped", G_CALLBACK(&PlotArea::on_click_stopped), self); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click)); + + GtkGesture* drag = gtk_gesture_drag_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 0); + g_signal_connect(drag, "drag-begin", G_CALLBACK(&PlotArea::on_drag_begin), self); + g_signal_connect(drag, "drag-update", G_CALLBACK(&PlotArea::on_drag_update), self); + g_signal_connect(drag, "drag-end", G_CALLBACK(&PlotArea::on_drag_end), self); + gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(drag)); + + // Vertical only, as the plan asks: a purely horizontal scroll is not a zoom, and + // because this controller never sees one it cannot be mistaken for one. GTK + // reports the unit per event, so a discrete wheel arrives in notches and a + // smooth device in surface pixels; both are converted before they reach the + // controller (see scroll_notches). + GtkEventController* scroll = + gtk_event_controller_scroll_new(GTK_EVENT_CONTROLLER_SCROLL_VERTICAL); + g_signal_connect(scroll, "scroll", G_CALLBACK(&PlotArea::on_scroll), self); + gtk_widget_add_controller(widget, scroll); + + // Hover: the controller's cursor hit test reads the last pointer position even + // while no button is down. + GtkEventController* motion = gtk_event_controller_motion_new(); + g_signal_connect(motion, "motion", G_CALLBACK(&PlotArea::on_motion), self); + gtk_widget_add_controller(widget, motion); + + GtkEventController* key = gtk_event_controller_key_new(); + g_signal_connect(key, "key-pressed", G_CALLBACK(&PlotArea::on_key_pressed), self); + gtk_widget_add_controller(widget, key); + + return self; +} + +PlotArea::PlotArea(Plot& plot, GtkWidget* widget) noexcept + : plot_(&plot), controller_(plot), widget_(widget) {} + +Rect PlotArea::device_viewport() const noexcept { return alloc_in_device(widget_); } + +int PlotArea::scale_factor() const noexcept { return gtk_widget_get_scale_factor(widget_); } + +Vec2 PlotArea::to_device(double x, double y) const noexcept { + // The factor `device_viewport()` scales the widget by, applied to a position and + // to the offsets derived from two of them: one convention on both sides is what + // makes a gesture land on the data under the cursor. + const f32 scale = static_cast(gtk_widget_get_scale_factor(widget_)); + return Vec2{static_cast(x) * scale, static_cast(y) * scale}; +} + +void PlotArea::refresh_viewport() noexcept { + // The controller owns no viewport: this hands it to the plot (which re-lays the + // chrome out with it) and refreshes the layout. `last_device_viewport()` is + // deliberately not assigned here -- it records the region a *frame* was drawn + // into, which is `render()`'s to set. + controller_.set_viewport(device_viewport()); +} + +// --- the GL signals ------------------------------------------------------- + +void PlotArea::realize() noexcept { + // A second realize means GTK created a new context for a widget that moved to + // another surface: the names the renderer holds belonged to the old one, and + // nothing here may outlive it. Released before anything of ours exists on the + // new context, so the deletes it issues can only name objects that are not + // there. + renderer_.reset(); + + gtk_gl_area_make_current(GTK_GL_AREA(widget_)); + GError* error = gtk_gl_area_get_error(GTK_GL_AREA(widget_)); + if (error != nullptr) { + // The context GTK was asked for does not exist: no GL entry point may be + // called, and the area draws nothing. Reported, not asserted -- a GTK signal + // handler has nowhere to return a failure to. + init_status_ = Status{ErrorCode::GLFailure, + "GtkGLArea could not create a GL 3.3 context; see the GTK warning above"}; + std::fprintf(stderr, "[plot::gtk4] GLFailure: %s (%s)\n", init_status_.message, + error->message != nullptr ? error->message : "no detail"); + return; + } + + g_unresolved_count = 0; + const bool loaded = plot::gl::load(&resolve_gl_proc); + // Printed before the failure branch: the names collected here are what explains + // a failure, so a load that failed has to report them too. + if (g_unresolved_count > 0) { + std::fprintf(stderr, + "[plot::gtk4] eglGetProcAddress() did not resolve %u required entry point(s);" + " plot::gl::load() %s for:", + g_unresolved_count, loaded ? "used its dlsym fallback" : "failed"); + for (u32 i = 0; i < g_unresolved_count; ++i) std::fprintf(stderr, " %s", g_unresolved[i]); + std::fprintf(stderr, "\n"); + } + if (!loaded) { + init_status_ = Status{ErrorCode::Unsupported, + "plot::gl::load() failed: the current context is missing a required" + " OpenGL 3.3 entry point"}; + std::fprintf(stderr, "[plot::gtk4] %s\n", init_status_.message); + return; + } + + renderer_.emplace(); + Status status; + if (!renderer_->init(&status)) { + init_status_ = status.code != ErrorCode::Ok ? status + : Status{ErrorCode::GLFailure, + "Renderer::init() failed"}; + // The renderer stays engaged: its destructor releases whatever the partial + // init built, and it runs in unrealize() with the context current. + std::fprintf(stderr, "[plot::gtk4] Renderer::init(): %s: %s\n", to_string(init_status_.code), + init_status_.message); + return; + } + init_status_ = Status{}; + refresh_viewport(); +} + +void PlotArea::unrealize() noexcept { + // The context is still alive here and can still be made current (GTK clears it + // after this signal), which is what the renderer's RAII handles need: a + // `glDelete*` against a context that is going away -- or against no context at + // all -- is worse than the leak it would avoid. + if (renderer_) { + gtk_gl_area_make_current(GTK_GL_AREA(widget_)); + if (gtk_gl_area_get_error(GTK_GL_AREA(widget_)) == nullptr) renderer_.reset(); + } + press_open_ = false; + drag_active_ = false; +} + +void PlotArea::render() noexcept { + // GTK guarantees the context is current and the area's FBO is bound when + // `::render` runs; making it current is a no-op in that case, and the error + // check is the assertion that it holds rather than an assumption. Both are + // `gtk_gl_area_make_current()`'s documented behaviour, and the check costs one + // call per frame -- the alternative is drawing through entry points that + // belonged to a context GTK has already thrown away. + gtk_gl_area_make_current(GTK_GL_AREA(widget_)); + if (gtk_gl_area_get_error(GTK_GL_AREA(widget_)) != nullptr) return; + if (!renderer_ || !renderer_->ready()) return; + + const Rect viewport = device_viewport(); + if (viewport.w < 1.0f || viewport.h < 1.0f) return; // nothing to draw into + + // The controller owns no viewport: this hands it to the plot (which re-lays the + // chrome out with it) and refreshes the layout, so every frame is laid out for + // the geometry of *this* frame. A resize changes the layout; it never scales a + // stale one. + controller_.set_viewport(viewport); + + const f64 started = now_s(); + const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0; + last_render_s_ = started; + if (frame_) frame_(*plot_, dt); + + renderer_->begin_frame(static_cast(viewport.w), static_cast(viewport.h), viewport); + renderer_->draw(*plot_); + renderer_->end_frame(); + + const f32 frame_ms = static_cast((now_s() - started) * 1.0e3); + last_frame_ms_ = frame_ms; + // Seeded by the first frame rather than started at zero, so the mean is the mean + // of the frames that ran from the first one. + mean_frame_ms_ = frames_ == 0 ? frame_ms : mean_frame_ms_ + 0.1f * (frame_ms - mean_frame_ms_); + last_viewport_ = viewport; + ++frames_; +} + +void PlotArea::on_realize(GtkGLArea*, void* self) noexcept { + static_cast(self)->realize(); +} + +void PlotArea::on_unrealize(GtkGLArea*, void* self) noexcept { + static_cast(self)->unrealize(); +} + +int PlotArea::on_render(GtkGLArea*, GdkGLContext*, void* self) noexcept { + static_cast(self)->render(); + // FALSE on purpose, not "I did not draw": `GtkGLArea::render`'s return value is + // accumulated with `g_signal_accumulator_true_handled`, so the first handler to + // return TRUE *stops the emission* -- and a host that connects its own handler + // (a screenshot read, a frame counter) would never be called. GTK itself does + // not read the value (it composites the FBO the area rendered into either way), + // and a GtkGLArea subclass overriding the vfunc is the only place TRUE means + // anything. + return FALSE; +} + +void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept { + (void)width; + (void)height; + // Nothing about the size is cached from these two, on purpose: device_viewport() + // re-derives it from the live allocation, which is what the input mapping reads + // as well. GTK's numbers are the same ones -- it reports the GL viewport in + // device pixels, and this area's `width * scale` is that -- but one derivation + // is what keeps a gesture, a frame and a screenshot from ever disagreeing. + // Refreshing it here means a gesture arriving between the resize and the next + // frame maps against the new geometry rather than the old one. + static_cast(self)->refresh_viewport(); +} + +// --- pointer input -------------------------------------------------------- + +void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y) noexcept { + if (press_open_) return; // one press per button-down sequence: see drag_begin + press_open_ = true; + press_button_ = button; + press_mods_ = mods; + + const Vec2 p = to_device(x, y); + pointer_ = p; + have_pointer_ = true; + + input::PointerEvent e; + e.x = p.x; + e.y = p.y; + e.dx = 0.0f; + e.dy = 0.0f; + e.button = button; + e.mods = mods; + e.down = true; + e.pressed = true; + controller_.on_pointer(e); + gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); +} + +void PlotArea::pointer_release(Vec2 position) noexcept { + if (!press_open_) return; + press_open_ = false; + + input::PointerEvent e; + e.x = position.x; + e.y = position.y; + e.dx = 0.0f; + e.dy = 0.0f; + e.button = press_button_; + e.mods = press_mods_; + e.down = false; + e.released = true; + controller_.on_pointer(e); + gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); +} + +void PlotArea::on_click_pressed(GtkGestureClick* gesture, int, double x, double y, + void* self) noexcept { + auto* area = static_cast(self); + const std::optional button = + button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture))); + if (!button) return; // a button the controller has no gesture for + // The modifiers come off the *event*, not the gesture: GTK reports the state as + // it was before the press, which is what the controller's gesture table reads. + area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), x, y); +} + +void PlotArea::on_click_released(GtkGestureClick* gesture, int, double x, double y, + void* self) noexcept { + (void)gesture; + auto* area = static_cast(self); + // The first release closes the gesture; a drag's own `drag-end` may already have + // done it (GTK emits both, `drag-end` first), and this is then a no-op. + area->pointer_release(area->to_device(x, y)); +} + +void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept { + (void)gesture; + auto* area = static_cast(self); + // A sequence can be cancelled rather than released -- GTK stops the click + // gesture as soon as the drag gesture claims the same sequence, which happens on + // the first motion of every drag. That stop must not end the gesture: the drag + // is still running, and its updates keep driving the plot. Only a stop with no + // drag in progress (an exotic cancellation by another widget or a device) closes + // the press, which it has to, or the controller would stay in whatever mode the + // press began. + if (area->press_open_ && !area->drag_active_) area->pointer_release(area->pointer_); +} + +void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y, + void* self) noexcept { + auto* area = static_cast(self); + area->drag_active_ = true; + area->drag_start_ = area->to_device(start_x, start_y); + area->drag_offset_ = Vec2{0.0f, 0.0f}; + area->pointer_ = area->drag_start_; + area->have_pointer_ = true; + + // GTK emits `drag-begin` before the click gesture's `pressed` (both while the + // press event is handled), so this is normally the second press of the sequence + // and `pointer_press` drops it. It is not always: if the click gesture never + // pressed -- a host that grabs the sequence, a GTK that reorders its gestures -- + // the press is opened here instead, so a drag cannot be lost for want of an + // edge. The button and modifiers come from the drag gesture for that case. + const std::optional button = + button_from(gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture))); + if (button) { + area->pointer_press(*button, gesture_mods(GTK_EVENT_CONTROLLER(gesture)), start_x, start_y); + } +} + +void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, + void* self) noexcept { + (void)gesture; + auto* area = static_cast(self); + // A drag with no press open is a drag of a button the adapter does not forward + // (see button_from): its motion must not reach the controller as a held-drag + // move, which is the one event the controller acts on from its button state. + if (!area->press_open_) return; + const Vec2 offset = area->to_device(offset_x, offset_y); + // The drag gesture reports the offset from the press point, the controller wants + // the movement since the previous sample: the difference of two offsets, both + // already scaled by the same factor as the viewport and the press. + const Vec2 delta = Vec2{offset.x - area->drag_offset_.x, offset.y - area->drag_offset_.y}; + area->drag_offset_ = offset; + const Vec2 position = Vec2{area->drag_start_.x + offset.x, area->drag_start_.y + offset.y}; + area->pointer_ = position; + area->have_pointer_ = true; + + input::PointerEvent e; + e.x = position.x; + e.y = position.y; + e.dx = delta.x; + e.dy = delta.y; + e.button = area->press_button_; + e.mods = area->press_mods_; + e.down = true; + area->controller_.on_pointer(e); + gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); +} + +void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, + void* self) noexcept { + (void)gesture; + auto* area = static_cast(self); + area->drag_active_ = false; + // The release carries the authoritative end of the gesture: the controller reads + // its position to finish a box zoom's selection, so this is the press point plus + // the final offset and not the last update's. + const Vec2 offset = area->to_device(offset_x, offset_y); + area->pointer_ = Vec2{area->drag_start_.x + offset.x, area->drag_start_.y + offset.y}; + area->pointer_release(area->pointer_); +} + +int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy, + void* self) noexcept { + auto* area = static_cast(self); + const ScrollUnit unit = gtk_event_controller_scroll_get_unit(scroll) == GDK_SCROLL_UNIT_SURFACE + ? ScrollUnit::Surface + : ScrollUnit::Wheel; + const f64 notches = scroll_notches(dy, unit); + if (notches == 0.0) { + // Not a zoom, so not this widget's event: returning FALSE lets an enclosing + // scrolled window scroll instead. A zero-notch zoom is not harmless -- it + // changes no range but still runs the range setters, which clear auto scale -- + // so it must not reach the controller either. + return FALSE; + } + + const f32 scale = static_cast(gtk_widget_get_scale_factor(area->widget_)); + Vec2 at{}; + if (!current_event_position(GTK_EVENT_CONTROLLER(scroll), scale, &at)) { + if (!area->have_pointer_) return FALSE; // no anchor to zoom about + at = area->pointer_; // the last position a pointer event reported + } + area->pointer_ = at; + area->have_pointer_ = true; + + input::WheelEvent e; + e.x = at.x; + e.y = at.y; + e.dx = static_cast(scroll_notches(dx, unit)); + e.dy = static_cast(notches); + e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll)); + area->controller_.on_wheel(e); + gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); + return TRUE; // consumed: the plot zoomed +} + +void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, void* self) noexcept { + auto* area = static_cast(self); + const Vec2 p = area->to_device(x, y); + area->pointer_ = p; + area->have_pointer_ = true; + + // Hover only, with `down` false whatever the buttons are doing: a motion event + // that claimed a button was held would be fed to `update_drag` as well, and a + // drag would then be applied twice -- once from the drag gesture's offset and + // once from this position's difference. + (void)motion; + input::PointerEvent e; + e.x = p.x; + e.y = p.y; + e.dx = 0.0f; + e.dy = 0.0f; + e.down = false; + area->controller_.on_pointer(e); +} + +// --- keyboard ------------------------------------------------------------- + +int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval, + unsigned int keycode, unsigned int state, void* self) noexcept { + (void)keycode; + (void)keys; + auto* area = static_cast(self); + // The keys are named by their *symbol*: GTK reports a layout's keyval, and the + // keypad's plus and minus are the same symbol as the main row's. `'='` is mapped + // with `'+'` so both spellings of "zoom in" work (on most layouts `+` *is* + // shift+`=`), the pair the GLFW and Qt adapters map. + int key = 0; + switch (gdk_keyval_to_unicode(keyval)) { + case static_cast(input::kKeyZoomIn): + case static_cast('='): key = input::kKeyZoomIn; break; + case static_cast(input::kKeyZoomOut): + case static_cast('_'): key = input::kKeyZoomOut; break; + case static_cast(input::kKeyFit): + case static_cast(input::kKeyFitUpper): key = input::kKeyFit; break; + default: return FALSE; // not a plot key: GTK and the window keep it + } + + input::KeyEvent e; + e.key = key; + e.mods = mods_from(state); + // GTK reports a held key as repeated presses and sends no release the controller + // would act on anyway (its actions are press-only), so the press is the event. + e.down = true; + area->controller_.on_key(e); + gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); + return TRUE; // consumed: the plot acted on it +} + +} // namespace plot::gtk4 diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp new file mode 100644 index 0000000..0ed33ef --- /dev/null +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -0,0 +1,307 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// GTK's own type declarations, spelled exactly as GTK spells them +// (`typedef struct _GtkWidget GtkWidget;` and so on). Forward-declared on +// purpose, like the GLFW host's `struct GLFWwindow`: this header is +// self-contained and needs no GTK include directory, so a consumer of the +// adapter does not inherit GTK's headers -- and, unlike a toolkit whose headers +// reach ``, either include order works, because these are the same +// typedefs GTK declares and GTK's headers pull in no GL header at all. +typedef struct _GtkWidget GtkWidget; +typedef struct _GtkGLArea GtkGLArea; +typedef struct _GtkGesture GtkGesture; +typedef struct _GtkGestureClick GtkGestureClick; +typedef struct _GtkGestureDrag GtkGestureDrag; +typedef struct _GtkEventControllerScroll GtkEventControllerScroll; +typedef struct _GtkEventControllerKey GtkEventControllerKey; +typedef struct _GtkEventControllerMotion GtkEventControllerMotion; +typedef struct _GdkGLContext GdkGLContext; + +namespace plot::gtk4 { + +/// One frame of host code, run by the render callback after the viewport is set +/// and before the frame is staged. It is where data is refreshed -- acquiring a +/// `data::StreamSeries`, re-fitting a live axis, moving cursors -- and `dt` is +/// the wall time since the previous call, in seconds. Optional; an area with no +/// callback just re-draws the plot as it stands. +using FrameFn = std::function; + +/// Embeds a `Plot` in a GTK4 widget: a `GtkGLArea` whose framebuffer the plot is +/// rendered into, with GTK's gestures and event controllers translated into +/// `plot::input` ones. +/// +/// ## Why a wrapper and not a subclass +/// +/// The plan calls for a `GtkWidget` subclass, and GTK4 cannot have one here: a +/// GObject type is registered with `G_DEFINE_TYPE`, which is a C macro that +/// declares the type's instance and class structs and a `*_get_type()` function, +/// and none of that is expressible around a C++ class with a vtable. GTK also +/// refuses to let an application derive from a type with private data +/// (`GtkGLArea`'s is private) outside C. So this is the factory shape instead: +/// `create()` builds a `gtk_gl_area_new()` instance, registers the signals and +/// controllers that drive it, and returns the wrapper that owns them. The +/// widget is GTK's; the wrapper is the widget's. +/// +/// ## What the area draws into +/// +/// `GtkGLArea` creates an FBO of its own and makes it the current draw target +/// before emitting `::render`, exactly the "currently bound framebuffer" +/// `Renderer` documents it draws into -- so this area, like the Qt widget, +/// creates no target of its own and the renderer's scissor-cleared viewport is +/// the area's whole rectangle. GTK then composites that FBO into the +/// scene graph as a texture. Nothing here renders to a texture or through a +/// second pass, and the plot cannot disturb GTK's own drawing by construction: +/// the only framebuffer the renderer is handed is one GTK created for this +/// widget, and GTK is the one that composites it. That is a structural argument, +/// not a tested property. +/// +/// ## The coordinate convention: device pixels, both sides +/// +/// Two pixel spaces meet here, exactly as they do in the GLFW, ImGui and Qt +/// adapters. GTK reports widget allocation and every input coordinate in +/// **logical** pixels (`gtk_widget_get_width()`, a gesture's `x`/`y`, a drag +/// offset), while the area's framebuffer -- what `Renderer::begin_frame()` takes +/// and what every `Rect` in this library speaks -- is in **device** pixels, +/// `gtk_widget_get_scale_factor()` times as large on a scaled display. +/// +/// This area converts everything into device pixels and keeps it there: +/// +/// * the viewport handed to the plot is `width * scale` x `height * scale`, +/// the exact size of the FBO GTK attaches (its `::resize` signal reports the +/// same numbers, at 1x and at 2x), and +/// * every pointer position, drag offset and wheel position is multiplied by +/// the same factor before it reaches the controller. +/// +/// `device_viewport()` and `to_device()` are the only two places that mapping +/// exists, so the viewport and the input cannot disagree -- and they must not: a +/// mismatch renders correctly but pans by the wrong amount, which is invisible on +/// an unscaled display and wrong on every other one. `scale_factor()` is an +/// integer GTK owns (GDK_SCALE, the compositor's, or both), so the mapping is an +/// exact multiplication, not a rounding of a ratio. +/// +/// Note that this also means the renderer's *style metrics* stay in device +/// pixels: at scale 2 the label font is 24 device px = 12 logical px, so labels +/// are smaller relative to the widget than at 1x. That is the same convention +/// the GLFW and Qt adapters use, and a host that wants a constant logical text +/// size scales `Renderer::style()` metrics by the factor itself. +/// +/// ## Input +/// +/// GTK decides which gesture a sequence is by recognition, so two gestures share +/// the work here, and the split is the one the observed signal order forces: +/// +/// * `GtkGestureDrag` (button 0: any) supplies the *motion*. Its +/// `drag-update` carries the offset from the press point, which is what a pan, +/// a box zoom, a cursor move and an axis zoom all measure. +/// * `GtkGestureClick` (button 0: any) supplies the *edges*: the press and the +/// release, with the button and the modifier state, exactly as the Qt +/// widget's `mousePressEvent`/`mouseReleaseEvent` do. It is also what begins +/// a cursor move: a ctrl+press on a cursor opens `DragMode::MoveCursor` there. +/// * `GtkEventControllerMotion` supplies hover (the cursor hit test reads the +/// last position even with no button down). Its positions are forwarded with +/// `down == false` on purpose: a motion event that claimed a button was down +/// would drive `update_drag` as well, and every drag would then be applied +/// twice, once from the drag offset and once from the motion delta. +/// * `GtkEventControllerScroll` (vertical only) supplies the wheel, in notches +/// (see `scroll_notches`). +/// * `GtkEventControllerKey` supplies `+` / `-` / `f`. +/// +/// GTK emits `drag-begin` *before* the click gesture's `pressed` (both are +/// emitted while the press event is handled), and a press that never moves still +/// produces `drag-begin`, `drag-end` and the click's press/release pair. The +/// adapter therefore opens its gesture from the first press it sees and closes it +/// from the first release, so the two gestures cannot double-press or +/// double-release the controller. +/// +/// ## Threading and repainting +/// +/// Everything here runs on the thread that owns the GTK main context -- realize, +/// render, every gesture handler -- which is the thread that owns the GL context. +/// A host that feeds the plot from a producer thread must acquire its data on +/// this thread (see the frame callback); the adapter never touches the plot from +/// anywhere else, and never joins a host's thread. +/// +/// GTK redraws the area when the widget is exposed, resized or asked to. This +/// area additionally queues a render after every input event that can change the +/// picture -- a press, a drag, a wheel or a plot key, but not a hover (the +/// renderer draws nothing that depends on where the pointer is) -- so a gesture is +/// visible without waiting for the host's next frame, and the frame rate stays the +/// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`. +/// +/// ## Headless use +/// +/// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a +/// read of that FBO (see the demo's `dump_area_ppm`), not of the window: GTK's +/// chrome lives outside it. The area also reports what happened to the GL context +/// rather than asserting: `init_status()` is `Ok` once the renderer is up, or the +/// failure that left the area drawing nothing. +class PlotArea { + public: + /// Creates the `GtkGLArea` that renders `plot`, plus the wrapper that drives + /// it. `plot` must outlive the widget. The area expands to fill its container; + /// a host that wants otherwise clears `GtkWidget:hexpand`/`vexpand`. + /// + /// The returned wrapper is **owned by the widget**: GTK frees it when the + /// widget is finalized (the GL resources are released earlier, in + /// `::unrealize`, while the context may still be made current). The caller + /// therefore never deletes it, and must not keep it past the widget's life. + static PlotArea* create(Plot& plot) noexcept; + + PlotArea(const PlotArea&) = delete; + PlotArea& operator=(const PlotArea&) = delete; + + /// Installs the per-frame hook (see `FrameFn`); an empty function removes it. + void set_frame_callback(FrameFn frame) { frame_ = std::move(frame); } + + /// The widget to put in a window or a box. Valid for the wrapper's whole life. + GtkWidget* widget() const noexcept { return widget_; } + + /// The plot this area renders. Valid for the widget's whole life. + Plot& plot() noexcept { return *plot_; } + const Plot& plot() const noexcept { return *plot_; } + /// The controller this area feeds, bound to `plot()`. + input::Controller& controller() noexcept { return controller_; } + const input::Controller& controller() const noexcept { return controller_; } + + /// The renderer, or null until a successful `::realize`. Valid from the first + /// render onwards, and after the widget's context exists. + Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; } + const Renderer* renderer() const noexcept { return renderer_ ? &*renderer_ : nullptr; } + + /// What `::realize` made of the context: `Ok`, or the failure that left the + /// area drawing nothing. A GTK signal handler cannot return a `Status` and + /// nothing may throw across GTK's callback, so the outcome is recorded here + /// instead. `ErrorCode::GLFailure` is what a `GtkGLArea` whose context could not + /// be created reports -- checked with `gtk_gl_area_get_error()` before any GL + /// entry point is called. + /// + /// A context that GTK loses *after* realize is not reported here: GTK 4.22 stops + /// emitting `::render` for an area that carries an error (measured: the raw + /// signal count freezes on the frame the error is set), so the render callback + /// -- whose `gtk_gl_area_get_error()` check is the assertion that the context is + /// usable -- is never entered again. A host sees that as an area that stops + /// staging frames: `frames_painted()` stops advancing. + const Status& init_status() const noexcept { return init_status_; } + + /// Frames actually staged, and the frame time of the last one (ms). The mean is + /// an exponential moving average seeded by the first frame, of the same + /// render-only span: the frame callback, `begin_frame`, `draw` and `end_frame`. + /// It excludes GTK's own compositing of the area, which happens outside this + /// callback and is not the library's or the adapter's work. + u64 frames_painted() const noexcept { return frames_; } + f32 last_frame_ms() const noexcept { return last_frame_ms_; } + f32 mean_frame_ms() const noexcept { return mean_frame_ms_; } + + /// The region the last frame was drawn into, in device pixels -- the rect + /// `begin_frame()` was handed. Zero-sized before the first frame. + Rect last_device_viewport() const noexcept { return last_viewport_; } + + /// The area's current rectangle in device pixels: the widget's allocation + /// scaled by `scale_factor()`. This is the plot's viewport, the size of the + /// framebuffer GTK attached, and -- a screenshot helper needs it -- the size of + /// a framebuffer read. Live geometry, never a cached size. + Rect device_viewport() const noexcept; + + /// The widget's scale factor (`gtk_widget_get_scale_factor()`): the one factor + /// both `device_viewport()` and every forwarded input coordinate is scaled by. + /// 0 before the widget is realized, which is also when nothing is allocated. + int scale_factor() const noexcept; + + private: + explicit PlotArea(Plot& plot, GtkWidget* widget) noexcept; + + /// `::realize`: make the context current, check it exists, load the GL entry + /// points and initialise the renderer. Runs again if GTK realizes the widget + /// twice (it moved to another surface), which is why it releases the previous + /// renderer first. + void realize() noexcept; + /// `::unrealize`: release the renderer's GL objects while the context can still + /// be made current. GTK frees the context at some point after this signal, and + /// a `glDelete*` against a freed context is worse than a leak. + void unrealize() noexcept; + /// `::render`: stage one frame into the FBO GTK bound. + void render() noexcept; + /// `::resize`: hand the new geometry to the plot now, so a gesture arriving + /// before the next frame maps against the new layout instead of the old one. + void refresh_viewport() noexcept; + + /// One pointer event, from whichever gesture saw it first. Owns the + /// press/release bookkeeping the two gestures share. + void pointer_press(input::Button button, u32 mods, double x, double y) noexcept; + void pointer_release(Vec2 position) noexcept; + /// Translates the gesture-reported position/delta in *logical* widget pixels + /// into device pixels. The one place that mapping exists. + Vec2 to_device(double x, double y) const noexcept; + + static void on_realize(GtkGLArea* area, void* self) noexcept; + static void on_unrealize(GtkGLArea* area, void* self) noexcept; + /// `gboolean` is `int`; a GtkGLArea render handler ignores the context. + static int on_render(GtkGLArea* area, GdkGLContext* context, void* self) noexcept; + static void on_resize(GtkGLArea* area, int width, int height, void* self) noexcept; + + static void on_click_pressed(GtkGestureClick* gesture, int n_press, double x, double y, + void* self) noexcept; + static void on_click_released(GtkGestureClick* gesture, int n_press, double x, double y, + void* self) noexcept; + static void on_click_stopped(GtkGestureClick* gesture, void* self) noexcept; + static void on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y, + void* self) noexcept; + static void on_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, + void* self) noexcept; + static void on_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, + void* self) noexcept; + static int on_scroll(GtkEventControllerScroll* scroll, double dx, double dy, + void* self) noexcept; + static void on_motion(GtkEventControllerMotion* motion, double x, double y, + void* self) noexcept; + /// `guint keyval, guint keycode, GdkModifierType state`, all 32-bit values; the + /// modifier enum is named by its size here because this header does not include + /// GDK (see the note at the top). + static int on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval, + unsigned int keycode, unsigned int state, void* self) noexcept; + + Plot* plot_ = nullptr; ///< declared first: the controller is built over it + input::Controller controller_; + GtkWidget* widget_ = nullptr; + std::optional renderer_; + FrameFn frame_; + Status init_status_; + + /// The button and modifiers of the gesture in progress, and whether one is: + /// the two gestures share one sequence, so exactly one press is open between a + /// button-down and its button-up. + bool press_open_ = false; + input::Button press_button_ = input::Button::Left; + u32 press_mods_ = 0; + /// The drag gesture's press point and last offset, in *device* pixels, and + /// whether a drag is recognised. The offset is cached rather than read per + /// event so a `drag-update` reports the movement since the previous one. + Vec2 drag_start_{}; + Vec2 drag_offset_{}; + bool drag_active_ = false; + /// The last pointer position seen, in device pixels: the wheel's anchor when a + /// scroll event carries no position of its own. + Vec2 pointer_{}; + bool have_pointer_ = false; + + u64 frames_ = 0; + f32 last_frame_ms_ = 0.0f; + f32 mean_frame_ms_ = 0.0f; + /// Wall clock at the last render start, or negative before the first one. + f64 last_render_s_ = -1.0; + Rect last_viewport_{}; +}; + +} // namespace plot::gtk4 diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 1595d08..99929ae 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -30,3 +30,12 @@ if(TARGET plot::qt6) target_link_libraries(plot_qt_demo PRIVATE plot::qt6 Threads::Threads) plot_apply_warnings(plot_qt_demo) endif() + +if(TARGET plot::gtk4) + # The demo owns the GtkApplication, the window and the header bar; the adapter is + # only the plot area, which the window adopts as its content. GTK's include + # directories come with plot::gtk4 (see the adapter's CMakeLists). + add_executable(plot_gtk_demo gtk_demo.cpp) + target_link_libraries(plot_gtk_demo PRIVATE plot::gtk4 Threads::Threads) + plot_apply_warnings(plot_gtk_demo) +endif() diff --git a/examples/gtk_demo.cpp b/examples/gtk_demo.cpp new file mode 100644 index 0000000..76d7eb8 --- /dev/null +++ b/examples/gtk_demo.cpp @@ -0,0 +1,604 @@ +// 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 ``, so unlike Qt there is no macro to dodge. +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 is `scale_factor` times this. +constexpr int kWindowWidth = 1200; +constexpr int kWindowHeight = 800; +/// The size the scripted resize before the exit frame goes to: different on both +/// axes, so a screenshot that kept the old layout is visibly the old size. +constexpr int kExitWidth = 820; +constexpr int kExitHeight = 620; + +/// y(t) for sample index `i`: sine + deterministic noise + a narrow spike, the +/// signal the other demos use. +f32 sample_at(u64 i) noexcept { + const f64 t = static_cast(i) * kDx; + f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t); + + u32 h = static_cast(i) * 2654435761u; + h ^= h >> 15; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + y += kNoiseAmp * (static_cast(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(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(clock::now() - start).count(); + const u64 due = static_cast(elapsed / kDx) + 1; + if (due > next) { + u64 n = due - next; + if (n > kMaxBatch) n = kMaxBatch; + batch_.resize(static_cast(n)); + for (u64 k = 0; k < n; ++k) { + batch_[static_cast(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(next + kBatch) * kDx; + std::this_thread::sleep_until( + start + std::chrono::duration_cast(std::chrono::duration(wake_s))); + } + } + + plot::data::StreamSeries* series_; + std::vector batch_; + std::atomic produced_{0}; + std::atomic 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(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(w); + const plot::usize height = static_cast(h); + std::vector 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 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(user_data); + // 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(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(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(viewport.w), + static_cast(viewport.h)); + std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path, + static_cast(viewport.w), static_cast(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(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(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 %d\n", + width_before, height_before, gtk_widget_get_width(demo.area->widget()), + gtk_widget_get_height(demo.area->widget()), static_cast(viewport.w), + static_cast(viewport.h), demo.area->scale_factor()); + std::printf("[gtk_demo] layout axes %.0fx%.0f at (%.0f,%.0f) -> %.0fx%.0f at (%.0f,%.0f): %s\n", + static_cast(before.axes.w), static_cast(before.axes.h), + static_cast(before.axes.x), static_cast(before.axes.y), + static_cast(after.axes.w), static_cast(after.axes.h), + static_cast(after.axes.x), static_cast(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(before.axes.x), static_cast(after.axes.x), + static_cast(before.axes.w), static_cast(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(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(&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(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 %d 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(demo.area->mean_frame_ms()), demo.area->scale_factor(), + static_cast(viewport.w), static_cast(viewport.h), + static_cast(axes.w), static_cast(axes.h), xr.min, xr.max, xr.span(), + p.x_axis(demo.axis_x).auto_scale ? 1 : 0, + static_cast(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(s.stats.visible_samples) / + static_cast(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(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(s.frames), + static_cast(demo.meter.frames), demo.meter.elapsed, + static_cast(s.mean_ms), + s.mean_ms > 0.0f ? 1.0e3 / static_cast(s.mean_ms) : 0.0, + static_cast(s.last_ms)); + std::printf("[gtk_demo] retained %llu samples, x [%.6f, %.6f] s\n", + static_cast(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(s.dense_spp), + s.stats.lod_traces > 0 ? "LOD pyramid" : "raw samples", + static_cast(s.stats.visible_samples), + static_cast(s.axes.w), static_cast(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(); // no thread pushes into the stream after this point + + 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; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 832c267..f613f57 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -56,6 +56,11 @@ endif() # tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has # to exist for it to compile. target_sources(plot_tests PRIVATE test_qt_pointer.cpp) + +# Same for the GTK adapter's input conversion (see adapters/gtk4/gtk_input.hpp): +# the wheel's unit and sign and the modifier mask are this project's logic, and a +# headless X server can only deliver the discrete-wheel branch of it. +target_sources(plot_tests PRIVATE test_gtk_input.cpp) target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters") # Same switches the library is built with: the cost tests read the counters diff --git a/tests/test_gtk_input.cpp b/tests/test_gtk_input.cpp new file mode 100644 index 0000000..1f3bb80 --- /dev/null +++ b/tests/test_gtk_input.cpp @@ -0,0 +1,101 @@ +// The GTK adapter's input translation, driven directly. +// +// Two pieces of it are this project's logic and not a toolkit's, and neither shows +// up in a screenshot: the wheel's unit and sign conversion, and the modifier mask. +// A headless X server delivers discrete wheel notches through XTEST, so the +// surface-pixel branch of `scroll_notches()` has no event to arrive on -- and the +// mask matters because GDK's state carries the mouse-button bits, which would +// otherwise ride into the controller's events. +// +// `gtk_input.hpp` names no GDK type (it mirrors the two scroll-unit values), so +// this compiles without GTK and without PLOT_BUILD_GTK4. +#include "harness.hpp" + +#include + +#include +#include + +using plot::f64; +using plot::u32; +using plot::gtk4::ScrollUnit; +using plot::gtk4::button_from; +using plot::gtk4::kSurfacePixelsPerNotch; +using plot::gtk4::mods_from; +using plot::gtk4::scroll_notches; + +// GDK's positive `dy` is a scroll *south* (the wheel pushed away from the user) and +// a `ScrollUnit::Wheel` delta is already one unit per detent, so a notch up -- +// `dy == -1.0`, what an X11 button-4 press arrives as -- has to leave as `+1.0`. +// Getting the sign wrong would invert every zoom, which looks like a working plot +// with an upside-down wheel. +PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) { + PLOT_CHECK_NEAR(scroll_notches(-1.0, ScrollUnit::Wheel), 1.0); + PLOT_CHECK_NEAR(scroll_notches(1.0, ScrollUnit::Wheel), -1.0); + PLOT_CHECK_NEAR(scroll_notches(-3.0, ScrollUnit::Wheel), 3.0); + PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0); +} + +// A smooth device reports surface pixels. Passing them through unscaled is the +// defect this pins: a 60-pixel flick is half a notch with the conversion and 60 +// notches -- a span multiplied by ~1e-4 -- without it, for a gesture the user +// cannot undo faster than it happens. +PLOT_TEST(gtk_scroll_surface_deltas_become_notches) { + PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Surface), 1.0); + PLOT_CHECK_NEAR(scroll_notches(-2.0 * kSurfacePixelsPerNotch, ScrollUnit::Surface), 2.0); + PLOT_CHECK_NEAR(scroll_notches(-60.0, ScrollUnit::Surface), 0.5); + PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -25.0); + // A smooth device's tail is not quantised away: the conversion is a pure scale, + // so a 0.12-pixel event stays a 0.001-notch zoom (0.015% of the span), exactly as + // the Qt adapter's `angleDelta() / 120` does for a fractional angle. What the + // caller can refuse is an *exactly* zero amount: no zoom, so the event is not this + // widget's (see the adapter's `on_scroll`). + PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch / 1000.0, ScrollUnit::Surface), 0.001, + 1e-12); + PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Surface), 0.0); +} + +// An amount the controller must refuse is not silently turned into a number here: +// `Controller::on_wheel` rejects a non-finite delta as a strict no-op, and that +// decision belongs to it alone. +PLOT_TEST(gtk_scroll_passes_a_non_finite_delta_through) { + PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Wheel))); + PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Surface))); +} + +// GDK reports the button bits inside the same state word as the modifiers -- a +// release arrives with `GDK_BUTTON1_MASK` (1 << 8) set, a right-drag with `1 << 10` +// -- so the translation is a mask. Without it every drag's events would carry bits +// no other adapter's do. +PLOT_TEST(gtk_mods_ignore_the_mouse_button_bits) { + PLOT_CHECK(mods_from(0x100u) == 0u); // GDK_BUTTON1_MASK + PLOT_CHECK(mods_from(0x400u) == 0u); // GDK_BUTTON3_MASK + PLOT_CHECK(mods_from(0x501u) == static_cast(plot::input::Mod::Shift)); + + const u32 shift = static_cast(plot::input::Mod::Shift); + const u32 ctrl = static_cast(plot::input::Mod::Ctrl); + const u32 alt = static_cast(plot::input::Mod::Alt); + const u32 super = static_cast(plot::input::Mod::Super); + + PLOT_CHECK(mods_from(1u) == shift); // GDK_SHIFT_MASK + PLOT_CHECK(mods_from(4u) == ctrl); // GDK_CONTROL_MASK + PLOT_CHECK(mods_from(8u) == alt); // GDK_ALT_MASK + PLOT_CHECK(mods_from(1u << 26) == super); // GDK_SUPER_MASK + PLOT_CHECK(mods_from(1u << 28) == super); // GDK_META_MASK, Command on macOS + PLOT_CHECK(mods_from(1u | 4u | 8u | (1u << 26)) == (shift | ctrl | alt | super)); + // GDK_LOCK_MASK (CapsLock) is not a modifier the controller names. + PLOT_CHECK(mods_from(2u) == 0u); +} + +// The gestures listen to "any button", so the numbers that reach the mapping are +// 1, 2, 3 and whatever the device reports. Only the three the controller has a +// gesture for are accepted: an unhandled button is dropped rather than defaulting +// to a left-drag pan. +PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) { + PLOT_CHECK(button_from(1u) == std::optional(plot::input::Button::Left)); + PLOT_CHECK(button_from(2u) == std::optional(plot::input::Button::Middle)); + PLOT_CHECK(button_from(3u) == std::optional(plot::input::Button::Right)); + PLOT_CHECK(!button_from(0u).has_value()); + PLOT_CHECK(!button_from(8u).has_value()); // a navigation button + PLOT_CHECK(!button_from(9u).has_value()); +}