feat(gtk4): GtkGLArea-based plot area and demo

This commit is contained in:
Martino Ferrari committed 2026-09-28 02:09:31 +02:00
1 parent 3c0e7763ab
commit 36cd3421f3
10 files changed
+1748 -2

No files matched your search

+34
View File
@@ -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
# <gtk4/gtk_plot_area.hpp> and <gtk4/gtk_input.hpp> -- 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)
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <optional>
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<u32>(input::Mod::Shift);
if ((gdk_state & kGdkControl) != 0u) out |= static_cast<u32>(input::Mod::Ctrl);
if ((gdk_state & kGdkAlt) != 0u) out |= static_cast<u32>(input::Mod::Alt);
if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) {
out |= static_cast<u32>(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<input::Button> 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
+584
View File
@@ -0,0 +1,584 @@
#include <gtk4/gtk_plot_area.hpp>
#include <gtk4/gtk_input.hpp>
#include <plot/gl/loader.hpp>
// GTK after this library's GL surface, the order every adapter here keeps. GTK's
// headers pull in no `<GL/gl.h>` -- 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 <gtk/gtk.h>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
// 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<int>(plot::gtk4::ScrollUnit::Wheel) == GDK_SCROLL_UNIT_WHEEL,
"ScrollUnit::Wheel must match GDK_SCROLL_UNIT_WHEEL");
static_assert(static_cast<int>(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 <typename Fn>
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<GetProcAddress>(dlsym(RTLD_DEFAULT, "eglGetProcAddress"));
static const GetProcAddress glx =
symbol_as<GetProcAddress>(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<f64>(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<u32>(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<f32>(x) * scale, static_cast<f32>(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<f32>(gtk_widget_get_scale_factor(widget));
return Rect{0.0f, 0.0f, static_cast<f32>(gtk_widget_get_width(widget)) * scale,
static_cast<f32>(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<PlotArea*>(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<f32>(gtk_widget_get_scale_factor(widget_));
return Vec2{static_cast<f32>(x) * scale, static_cast<f32>(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<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
renderer_->draw(*plot_);
renderer_->end_frame();
const f32 frame_ms = static_cast<f32>((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<PlotArea*>(self)->realize();
}
void PlotArea::on_unrealize(GtkGLArea*, void* self) noexcept {
static_cast<PlotArea*>(self)->unrealize();
}
int PlotArea::on_render(GtkGLArea*, GdkGLContext*, void* self) noexcept {
static_cast<PlotArea*>(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<PlotArea*>(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<PlotArea*>(self);
const std::optional<input::Button> 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<PlotArea*>(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<PlotArea*>(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<PlotArea*>(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<input::Button> 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<PlotArea*>(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<PlotArea*>(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<PlotArea*>(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<f32>(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<f32>(scroll_notches(dx, unit));
e.dy = static_cast<f32>(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<PlotArea*>(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<PlotArea*>(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<guint32>(input::kKeyZoomIn):
case static_cast<guint32>('='): key = input::kKeyZoomIn; break;
case static_cast<guint32>(input::kKeyZoomOut):
case static_cast<guint32>('_'): key = input::kKeyZoomOut; break;
case static_cast<guint32>(input::kKeyFit):
case static_cast<guint32>(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
+307
View File
@@ -0,0 +1,307 @@
#pragma once
#include <functional>
#include <optional>
#include <utility>
#include <plot/core/rect.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/input/controller.hpp>
#include <plot/math/vec2.hpp>
#include <plot/plot/plot.hpp>
#include <plot/render/renderer.hpp>
// 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 `<GL/gl.h>`, 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<void(Plot&, f64 dt)>;
/// 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> 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