903 lines
43 KiB
C++
903 lines
43 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#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>
|
|
#include <limits>
|
|
#include <memory>
|
|
|
|
// 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");
|
|
static_assert(plot::gtk4::kGdkKeyEscape == GDK_KEY_Escape, "the mirrored Escape keyval must match GDK's");
|
|
static_assert(plot::gtk4::kGdkKeyLeft == GDK_KEY_Left, "the mirrored Left keyval must match GDK's");
|
|
static_assert(plot::gtk4::kGdkKeyUp == GDK_KEY_Up, "the mirrored Up keyval must match GDK's");
|
|
static_assert(plot::gtk4::kGdkKeyRight == GDK_KEY_Right, "the mirrored Right keyval must match GDK's");
|
|
static_assert(plot::gtk4::kGdkKeyDown == GDK_KEY_Down, "the mirrored Down keyval 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
|
|
/// 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<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));
|
|
|
|
// The two-finger pinch: `scale-changed` carries the cumulative zoom since the
|
|
// gesture began, and the gesture's bounding-box centre is the focal point -- the
|
|
// pair `ZoomAnchor` folds into the incremental `GestureEvent` the controller
|
|
// takes (zoom about the focal point, focal movement as the pan). No button:
|
|
// unlike the click/drag pair this gesture is touch-only by nature, and a mouse
|
|
// never produces one.
|
|
GtkGesture* zoom = gtk_gesture_zoom_new();
|
|
g_signal_connect(zoom, "begin", G_CALLBACK(&PlotArea::on_zoom_begin), self);
|
|
g_signal_connect(zoom, "scale-changed", G_CALLBACK(&PlotArea::on_zoom_scale_changed), self);
|
|
g_signal_connect(zoom, "end", G_CALLBACK(&PlotArea::on_zoom_end), self);
|
|
gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(zoom));
|
|
|
|
// 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.
|
|
if (figure_ != nullptr) {
|
|
lay_figure_out(device_viewport());
|
|
return;
|
|
}
|
|
controller_.set_viewport(device_viewport());
|
|
}
|
|
|
|
// --- figure mode ----------------------------------------------------------
|
|
|
|
void PlotArea::set_figure(Figure* figure) noexcept {
|
|
figure_ = figure;
|
|
row_controllers_.clear();
|
|
last_row_ = 0;
|
|
// A new figure holds no crosshair this area has spread, and no captured press row:
|
|
// the controllers are rebuilt below, so an open press would name one that is gone.
|
|
spread_active_ = false;
|
|
spread_x_ = 0.0;
|
|
spread_y_ = 0.0;
|
|
press_open_ = false;
|
|
press_row_ = 0;
|
|
drag_.have = false;
|
|
zoom_.end();
|
|
if (figure_ == nullptr) {
|
|
row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_`
|
|
return;
|
|
}
|
|
// One controller per **plot** (a row of several cells has one for each), indexed by the
|
|
// flattened plot index the figure uses.
|
|
const u32 plots = figure_->plot_count();
|
|
row_controllers_.reserve(plots);
|
|
for (u32 i = 0; i < plots; ++i) {
|
|
row_controllers_.push_back(std::make_unique<input::Controller>(figure_->plot(i)));
|
|
}
|
|
// The new figure's geometry goes into the cache now, not at the next frame: an event
|
|
// arriving between `set_figure()` and the first render -- which is every event a
|
|
// headless probe injects -- must be routed against the figure it was just handed.
|
|
lay_figure_out(device_viewport());
|
|
}
|
|
|
|
void PlotArea::lay_figure_out(Rect px) noexcept {
|
|
figure_->set_viewport(px);
|
|
refresh_row_rects();
|
|
for (u32 i = 0; i < row_controllers_.size() && i < row_rects_.size(); ++i) {
|
|
// Each row's own rect, which is the viewport `Figure::layout()` just gave that
|
|
// row's `Plot`: the controller and the plot must agree on it or a gesture lands on
|
|
// the wrong pixels. Set here rather than by the controller, which owns no viewport.
|
|
row_controllers_[i]->set_viewport(row_rects_[i]);
|
|
}
|
|
}
|
|
|
|
void PlotArea::refresh_row_rects() noexcept {
|
|
// The one `Figure::layout()` per call: every event handler reads `row_rects_`
|
|
// instead of paying this again, which is the whole point of the cache. The rects are
|
|
// the figure's **cells** (one per plot, flattened), which for a figure with one cell
|
|
// per row are exactly its row rects.
|
|
row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector<Rect>{};
|
|
}
|
|
|
|
input::Controller* PlotArea::row_controller(u32 row) noexcept {
|
|
return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr;
|
|
}
|
|
|
|
input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept {
|
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
|
// The cached rects `refresh_row_rects()` filled -- the same ones the last frame drew
|
|
// -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of
|
|
// pointer motion over a multi-row figure would re-lay every row out per move.
|
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
|
const u32 row = row_in(row_rects_, x, y);
|
|
input::Controller& controller = *row_controllers_[row];
|
|
controller.set_viewport(row_rects_[row]);
|
|
return controller;
|
|
}
|
|
|
|
u32 PlotArea::row_at(double x, double y) const noexcept {
|
|
if (figure_ == nullptr || row_rects_.empty()) return 0;
|
|
const Vec2 p = to_device(x, y);
|
|
return row_in(row_rects_, p.x, p.y);
|
|
}
|
|
|
|
input::Controller& PlotArea::target_controller(f32 x, f32 y) noexcept {
|
|
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
|
|
if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_;
|
|
// The capture: while a press is open the gesture belongs to the row that press
|
|
// landed in, however far the pointer has travelled since. Handing a `drag-update` or
|
|
// the release to the row the pointer is over *now* would leave the first row's drag
|
|
// open for ever -- its controller never sees the release that ends it -- and let a
|
|
// later gesture resume it from the stale anchor.
|
|
u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, y);
|
|
// Re-read against this frame's rects: the widget can be resized mid-drag, and the
|
|
// guard keeps a stale index from reaching past the controller list.
|
|
if (row >= row_controllers_.size()) row = 0;
|
|
last_row_ = row;
|
|
input::Controller& controller = *row_controllers_[row];
|
|
controller.set_viewport(row_rects_[row]);
|
|
return controller;
|
|
}
|
|
|
|
u32 PlotArea::row_in(const std::vector<Rect>& rows, f32 x, f32 y) noexcept {
|
|
if (rows.empty()) return 0;
|
|
// The cell the pixel is in, or -- when it is in none of them, which is the row or
|
|
// column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the
|
|
// figure's own margins -- the cell whose rect is nearest (squared distance from the
|
|
// pixel to the rect). By distance rather than by "the first that matches": a gesture
|
|
// in a visible gap must not default to plot 0. For a figure with one cell per row
|
|
// every cell spans the figure's width, so this reduces to the nearest row by y.
|
|
u32 row = 0;
|
|
f32 best = std::numeric_limits<f32>::infinity();
|
|
for (u32 i = 0; i < rows.size(); ++i) {
|
|
const Rect& r = rows[i];
|
|
const f32 dx = x < r.x ? r.x - x : (x >= r.right() ? x - r.right() : 0.0f);
|
|
const f32 dy = y < r.y ? r.y - y : (y >= r.bottom() ? y - r.bottom() : 0.0f);
|
|
const f32 distance = dx * dx + dy * dy;
|
|
if (distance < best) {
|
|
best = distance;
|
|
row = i;
|
|
if (distance == 0.0f) break; // inside this cell: nothing can be nearer
|
|
}
|
|
}
|
|
return row;
|
|
}
|
|
|
|
u32 PlotArea::pointer_row() const noexcept {
|
|
if (row_rects_.empty()) return 0;
|
|
// The captured row while a press owns the gesture, so an in-flight drag keeps its
|
|
// highlight even after the pointer has left the row it began in.
|
|
if (press_open_ && press_row_ < row_rects_.size()) return press_row_;
|
|
return row_in(row_rects_, pointer_.x, pointer_.y);
|
|
}
|
|
|
|
void PlotArea::sync_selection_frames() noexcept {
|
|
const auto frame_for = [](input::Controller& c) -> Rect {
|
|
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
|
|
};
|
|
if (figure_ != nullptr) {
|
|
for (u32 i = 0; i < row_controllers_.size(); ++i) {
|
|
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
|
|
// with, and a store of the same value is what keeps this path free of anything else.
|
|
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
|
|
}
|
|
return;
|
|
}
|
|
plot_->set_selection_rect(frame_for(controller_));
|
|
}
|
|
|
|
void PlotArea::sync_figure_crosshair() noexcept {
|
|
if (row_rects_.empty() || row_controllers_.empty()) return;
|
|
const u32 row_count = static_cast<u32>(row_rects_.size());
|
|
|
|
// Which row's crosshair is the hover. With a pointer of this area's own it is the row
|
|
// that pointer's events go to -- the same rule the routing uses, so the hover follows
|
|
// the gesture it began. A headless probe injects its events straight into a row
|
|
// controller and the area never sees a pointer, so there the hover is the row whose
|
|
// crosshair is *not* the one last spread: the figure writes one value to every row,
|
|
// so a row holding anything else was changed from outside. Nothing differing means no
|
|
// new hover, and the figure keeps the one it has.
|
|
u32 hovered = row_count;
|
|
if (have_pointer_) {
|
|
hovered = pointer_row();
|
|
} else {
|
|
for (u32 i = 0; i < row_count; ++i) {
|
|
const Crosshair& candidate = figure_->plot(i).crosshair();
|
|
// A row differs when its active state, its x, or the y of *its own* horizontal
|
|
// line differs from what was spread: a pointer moving to another row at the same
|
|
// x is a new hover and must move the one horizontal line, not leave the old one.
|
|
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
|
|
if (candidate.active != spread_active_ ||
|
|
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
|
|
hovered = i;
|
|
break;
|
|
}
|
|
}
|
|
if (hovered >= row_count) return;
|
|
}
|
|
|
|
// One x to every row, and the hovered row's **own** y only to that row (spec §17):
|
|
// the rows of a figure share x and not y -- row 0 may be volts and row 1 degrees --
|
|
// so a horizontal line spread across the figure would mean a different value in each.
|
|
// A row whose controller has *no* crosshair (the pointer left its axes, the only
|
|
// thing that clears one) clears the figure's, so the rows cannot be left showing a
|
|
// hover the pointer has abandoned.
|
|
const Plot& row = figure_->plot(hovered);
|
|
if (row.crosshair().active) {
|
|
if (row.crosshair().horizontal) {
|
|
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
|
|
} else {
|
|
// Vertical-only: a caller placed it through the single-coordinate
|
|
// `Figure::set_crosshair()`. The four-argument overload would turn the horizontal
|
|
// line on at the stored (meaningless) y == 0, clamped to the axes edge and made
|
|
// permanent, so only the vertical line is promoted.
|
|
figure_->set_crosshair(row.crosshair().x, true);
|
|
}
|
|
spread_x_ = row.crosshair().x;
|
|
spread_y_ = row.crosshair().y;
|
|
spread_active_ = true;
|
|
} else {
|
|
figure_->clear_crosshair();
|
|
spread_active_ = false;
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
zoom_.end();
|
|
}
|
|
|
|
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.
|
|
if (figure_ != nullptr) {
|
|
// The figure's one layout for this frame: it hands every row its rect (and so
|
|
// every row's `Plot` its viewport) and fills the rect cache the routing of
|
|
// whatever GTK delivers before the next frame answers from.
|
|
lay_figure_out(viewport);
|
|
// One hover, every row: the crosshair the row under the pointer placed is applied
|
|
// to the whole figure here, after this frame's events have been handled and before
|
|
// anything lays the rows out again. Here rather than in a handler, so a headless
|
|
// probe that injects straight into a row controller is synced too.
|
|
sync_figure_crosshair();
|
|
// Only the row the pointer's events go to republishes its own hot handles, so a row
|
|
// the pointer has left would keep the `HotHandles` it last published and go on
|
|
// drawing a hovered marker or cursor grab as if the pointer were still on it. Clear
|
|
// every *other* row here, where that row is known.
|
|
if (have_pointer_ && !row_rects_.empty()) {
|
|
const u32 active = pointer_row();
|
|
for (u32 i = 0; i < row_rects_.size() && i < row_controllers_.size(); ++i) {
|
|
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
|
|
}
|
|
}
|
|
} else {
|
|
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;
|
|
// A figure's rows take the pointer, but the frame callback is still given a `Plot&`:
|
|
// row 0 is the one it gets, so the callback argument and `figure()->plot(0)` agree.
|
|
if (frame_) frame_(figure_ != nullptr ? figure_->plot(0u) : *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;
|
|
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
|
|
// with the previous frame's scale is not the one being drawn.
|
|
sync_selection_frames();
|
|
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
|
|
if (figure_ != nullptr) {
|
|
renderer_->draw(*figure_);
|
|
} else {
|
|
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 {
|
|
auto* area = static_cast<PlotArea*>(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<f32>(gtk_widget_get_width(area->widget_)),
|
|
static_cast<f32>(gtk_widget_get_height(area->widget_))},
|
|
Vec2{static_cast<f32>(width), static_cast<f32>(height)});
|
|
area->refresh_viewport();
|
|
}
|
|
|
|
// --- pointer input --------------------------------------------------------
|
|
|
|
void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y,
|
|
u32 time_ms) noexcept {
|
|
if (press_open_) return; // one press per button-down sequence: see drag_begin
|
|
|
|
const Vec2 p = to_device(x, y);
|
|
// The figure row this gesture belongs to for its whole life, claimed from the press's
|
|
// own position *before* the event is dispatched (see `target_controller()`). Harmless
|
|
// and unread while no figure is set.
|
|
press_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0;
|
|
press_open_ = true;
|
|
press_button_ = button;
|
|
press_mods_ = mods;
|
|
|
|
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;
|
|
e.time_s = pointer_time_s(time_ms); // the double-click detector differences these
|
|
target_controller(p.x, p.y).on_pointer(e);
|
|
gtk_gl_area_queue_render(GTK_GL_AREA(widget_));
|
|
}
|
|
|
|
void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept {
|
|
if (!press_open_) return;
|
|
// Resolved while the press is still open, so the release reaches the row the press
|
|
// claimed rather than the row the pointer ended over -- the controller that owns the
|
|
// drag is the only one that can close it.
|
|
input::Controller& target = target_controller(position.x, position.y);
|
|
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;
|
|
e.time_s = pointer_time_s(time_ms);
|
|
target.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,
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
}
|
|
|
|
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),
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
}
|
|
|
|
void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
|
|
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_.have) {
|
|
area->pointer_release(
|
|
area->pointer_,
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
}
|
|
}
|
|
|
|
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
|
|
void* self) noexcept {
|
|
auto* area = static_cast<PlotArea*>(self);
|
|
area->drag_.begin(static_cast<f32>(start_x), static_cast<f32>(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<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,
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
}
|
|
}
|
|
|
|
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;
|
|
// 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;
|
|
e.time_s = area->pointer_time_s(
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
// The press's row, not the row the pointer has reached: `press_open_` is true here,
|
|
// so a drag that crosses a row boundary stays with the controller that began it.
|
|
area->target_controller(sample.position.x, sample.position.y).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 {
|
|
auto* area = static_cast<PlotArea*>(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, gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture)));
|
|
}
|
|
|
|
// --- the pinch ------------------------------------------------------------
|
|
|
|
void PlotArea::on_zoom_begin(GtkGestureZoom* gesture, GdkEventSequence*, void* self) noexcept {
|
|
auto* area = static_cast<PlotArea*>(self);
|
|
// The focal point is the centre of the bounding box around the pointers the
|
|
// gesture holds, in logical widget pixels -- the same space every other
|
|
// gesture coordinate here arrives in, and what `to_device()` maps.
|
|
double cx = 0.0;
|
|
double cy = 0.0;
|
|
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
|
|
area->zoom_.begin(static_cast<f32>(gtk_gesture_zoom_get_scale_delta(gesture)),
|
|
Vec2{static_cast<f32>(cx), static_cast<f32>(cy)});
|
|
}
|
|
|
|
void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale,
|
|
void* self) noexcept {
|
|
auto* area = static_cast<PlotArea*>(self);
|
|
double cx = 0.0;
|
|
double cy = 0.0;
|
|
gtk_gesture_get_bounding_box_center(GTK_GESTURE(gesture), &cx, &cy);
|
|
// The `scale` argument and `gtk_gesture_zoom_get_scale_delta()` are the same
|
|
// cumulative number -- the zoom since `::begin` -- so the folded event is this
|
|
// *frame's* zoom factor about the fingers' centre, plus the centre's movement,
|
|
// which is the two-finger pan. An update without a begin folds to a no-op (see
|
|
// `ZoomAnchor::update`), so a gesture whose start the host never saw zooms
|
|
// nothing rather than zooming from a made-up anchor.
|
|
const input::GestureEvent g = area->zoom_.update(
|
|
static_cast<f32>(scale), Vec2{static_cast<f32>(cx), static_cast<f32>(cy)}, area->mapping_);
|
|
// The folded event carries the focal point, which is the position this pinch belongs
|
|
// to; while a press is open the capture overrides it, as it does for a drag.
|
|
area->target_controller(g.x, g.y).on_gesture(g);
|
|
gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_));
|
|
}
|
|
|
|
void PlotArea::on_zoom_end(GtkGestureZoom*, GdkEventSequence*, void* self) noexcept {
|
|
// No trailing event: the last `scale-changed` carried the gesture's final zoom,
|
|
// and `end` only closes the folding so a straggler update zooms nothing.
|
|
static_cast<PlotArea*>(self)->zoom_.end();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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<f32>(scroll_notches(dx, unit));
|
|
e.dy = static_cast<f32>(notches);
|
|
e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll));
|
|
area->target_controller(at.x, at.y).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;
|
|
e.time_s = PlotArea::pointer_time_s(
|
|
gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion)));
|
|
area->target_controller(p.x, p.y).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 whole translation lives in gtk_input.hpp (see `key_from`): the function
|
|
// keys -- arrows, Escape -- are matched on the *keyval*, which is layout- and
|
|
// level-independent for them, and the printable ones on the Unicode they decode
|
|
// to, which is what makes `+` and shift+`=` (usually the same physical key) both
|
|
// zoom in, and the keypad's plus and minus come along for free. `0` is "no plot
|
|
// key": GTK and the window keep it.
|
|
const int key = key_from(static_cast<u32>(keyval), static_cast<u32>(gdk_keyval_to_unicode(keyval)));
|
|
if (key == 0) return FALSE;
|
|
|
|
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;
|
|
// A key has no position of its own, so it goes to the row the last event was routed
|
|
// to -- the row the user is looking at.
|
|
input::Controller& controller =
|
|
(area->figure_ == nullptr || area->last_row_ >= area->row_controllers_.size())
|
|
? area->controller_
|
|
: *area->row_controllers_[area->last_row_];
|
|
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
|