The factor both the viewport and every forwarded input coordinate use came from `gtk_widget_get_scale_factor()`, which GTK documents as "the next higher integer value" under fractional scaling. It is now derived by `DeviceMapping::from_sizes()` from the widget's logical allocation and the device size `GtkGLArea::resize` reports -- the viewport that signal's default handler sets with `glViewport(0, 0, w, h)` -- so the viewport, the renderer and input are one value by construction. The public `int scale_factor()` became `f32 device_scale()`. `kSurfacePixelsPerNotch` is now 100.0 and documented as a *calibration* with a stated assumption, not a unit conversion attributed to axis_value120/Qt. `::unrealize` releases the renderer unconditionally, after making the context current: the error it used to check says nothing about whether GL names exist (creation failure is the path that returns before a renderer is built), and skipping the release left the deletes to the wrapper's destructor, with no context. The drag anchor caches its press point and offsets in logical pixels and scales the difference by the factor in force now, so a scale change mid-drag cannot become a phantom pan. The demo stops its producer in `on_teardown`, before destroying the window whose renderer the stream feeds.
339 lines
18 KiB
C++
339 lines
18 KiB
C++
#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>
|
|
|
|
#include <gtk4/gtk_input.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. Nor does
|
|
// `gtk_input.hpp`, included above for the device-pixel mapping and the drag
|
|
// anchor, name a GDK type: it mirrors the two scroll-unit values and GDK's
|
|
// modifier bits as plain numbers.
|
|
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,
|
|
/// the pixels of the GL viewport this area renders into.
|
|
///
|
|
/// This area converts everything into device pixels and keeps it there:
|
|
///
|
|
/// * the viewport handed to the plot is the device size GTK reports in
|
|
/// `::resize`, which is the size of the viewport that signal's default
|
|
/// handler sets -- the exact size of the FBO GTK attaches and the renderer
|
|
/// draws into -- and
|
|
/// * every pointer position, drag offset and wheel position is multiplied by
|
|
/// that same factor before it reaches the controller.
|
|
///
|
|
/// The factor is therefore *derived*, from those two sizes and from nothing else:
|
|
/// `DeviceMapping::from_sizes()` divides the device size by the widget's logical
|
|
/// allocation, and it is the one value both sides read. No scale accessor takes
|
|
/// part: `gtk_widget_get_scale_factor()` is documented to return "the next higher
|
|
/// integer value" under fractional scaling (so a 1.25 or 1.75 display would map
|
|
/// input by 2), and `gdk_surface_get_scale()` describes the surface rather than
|
|
/// the FBO the area was given. The factor is an exact ratio, not an integer.
|
|
///
|
|
/// `device_viewport()` and `to_device()` are the only two places the mapping is
|
|
/// read, so the viewport and the input cannot disagree -- and they must not: a
|
|
/// mismatch renders correctly but pans by the wrong amount and puts a wheel's
|
|
/// anchor in the wrong place, which is invisible on an unscaled display and wrong
|
|
/// on every other one.
|
|
///
|
|
/// The *anchor* a drag's movement is measured from is the one thing kept in
|
|
/// logical pixels: the factor can change between the press and an update (the
|
|
/// window moving to a display with another scale, GTK re-emitting `::resize`), and
|
|
/// a delta is then a logical difference scaled by the factor in force now, never
|
|
/// the difference of two device positions (see `DragAnchor`).
|
|
///
|
|
/// 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 device size GTK last
|
|
/// reported in `::resize`, which is the size of the viewport that signal's
|
|
/// default handler set. 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. Zero-sized until GTK reports a size, which is also when there is
|
|
/// nothing to draw into.
|
|
Rect device_viewport() const noexcept;
|
|
|
|
/// Device pixels per logical pixel: the factor `DeviceMapping::from_sizes()`
|
|
/// derives from the widget's logical allocation and the device size `::resize`
|
|
/// reported, and the one factor both `device_viewport()` and every forwarded
|
|
/// input coordinate is scaled by. `1.0` before the widget is allocated, and
|
|
/// fractional under fractional scaling -- unlike `gtk_widget_get_scale_factor()`,
|
|
/// which returns its ceiling.
|
|
f32 device_scale() 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 -- unconditionally, and by making it current first. GTK clears
|
|
/// the context and the area's error in its own unrealize vfunc, which runs after
|
|
/// this signal's handlers, so this is the last moment at which a `glDelete*` can
|
|
/// name a live context; afterwards the wrapper's destruction would issue those
|
|
/// deletes with none.
|
|
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 anchor: its press point and last offset, in *logical*
|
|
/// pixels. Logical on purpose: a factor change between two samples of a drag
|
|
/// cannot then turn into a phantom movement (see `DragAnchor`). The offset is
|
|
/// cached rather than re-read per event, so a `drag-update` reports the movement
|
|
/// since the previous one, and `have` is whether a drag is recognised.
|
|
DragAnchor drag_;
|
|
/// 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;
|
|
/// The device-pixel mapping in force, derived in `::resize` from the widget's
|
|
/// logical allocation and the device size GTK reported. `1.0` and a zero
|
|
/// viewport until then, which is before anything is allocated or drawn.
|
|
DeviceMapping mapping_;
|
|
|
|
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
|