feat(gtk4): GtkGLArea-based plot area and demo
This commit is contained in:
1 parent
3c0e7763ab
commit
36cd3421f3
10 files changed
+1748
-2
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user