#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 /// at `mapping`. 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, const DeviceMapping& mapping, 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 = mapping.to_device(x, y); return true; } } // 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 mapping_.viewport; } f32 PlotArea::device_scale() const noexcept { return mapping_.factor; } Vec2 PlotArea::to_device(double x, double y) const noexcept { // The mapping `device_viewport()` is the viewport of, applied to a position and // to the offsets derived from two of them: one mapping on both sides is what // makes a gesture land on the data under the cursor. return mapping_.to_device(x, y); } 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 { // Made current first: the renderer's RAII handles issue `glDelete*` from their // destructors, and GTK clears the context in its own unrealize vfunc, which runs // after this signal's handlers -- so this is the last moment at which those // deletes can name a live context. gtk_gl_area_make_current(GTK_GL_AREA(widget_)); // Released unconditionally. The area's error says nothing about whether the // renderer holds names to delete: it is set when context *creation* failed, and // that is the path `realize()` returns from before it builds a renderer, so a // renderer present here has GL objects behind it. Leaving it for the wrapper's // destructor -- which runs at widget finalisation, after GTK has freed the // context -- would issue those deletes with nothing current, which is exactly // what the old error check was trying to avoid. `make_current()` and `reset()` // are no-ops on a widget that never got a context and on an empty renderer. renderer_.reset(); press_open_ = false; drag_.have = 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); // The mapping's factor is the display's scale, so the renderer draws its logical // style metrics at the same ratio the viewport and every forwarded coordinate are // in: a 12 px label is the same physical size on a 1x and a 2x widget. It is the // `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the // ceiling under fractional scaling and would over-scale the chrome by up to 60%. renderer_->style().ui_scale = mapping_.factor; 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 { auto* area = static_cast(self); // GTK's two sizes, and the one place the mapping is derived from them. `width` // and `height` are the device pixels of the viewport this signal's default // handler is about to set -- `glViewport(0, 0, width, height)` in GTK's own // `gtk_gl_area_resize()` -- and the widget's width and height, which GTK has // already updated when it emits this, are the logical pixels that same rectangle // is reported in. Their ratio is the factor for the viewport *and* for every // forwarded coordinate; refreshing here means a gesture arriving between this // resize and the next frame maps against the new geometry rather than the old. area->mapping_ = DeviceMapping::from_sizes( Vec2{static_cast(gtk_widget_get_width(area->widget_)), static_cast(gtk_widget_get_height(area->widget_))}, Vec2{static_cast(width), static_cast(height)}); area->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_.have) 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_.begin(static_cast(start_x), static_cast(start_y)); area->pointer_ = area->drag_.position(area->mapping_); 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; // The anchor stays logical (see `DragAnchor`): the gesture reports the offset // from the press point in logical pixels, and this is a logical difference // scaled by the factor in force *now*, so a factor change between two samples // cannot turn into a phantom movement of its own. const DragAnchor::Sample sample = area->drag_.update(offset_x, offset_y, area->mapping_); area->pointer_ = sample.position; area->have_pointer_ = true; input::PointerEvent e; e.x = sample.position.x; e.y = sample.position.y; e.dx = sample.delta.x; e.dy = sample.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); // 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 offset `drag-end` itself reports -- which may be past the last update's -- // and not the last update's position. const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_); area->pointer_ = position; area->pointer_release(position); } 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; } Vec2 at{}; if (!current_event_position(GTK_EVENT_CONTROLLER(scroll), area->mapping_, &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