fix(gtk4): derive the device-pixel factor from GTK's two sizes, and four more review fixes

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.
This commit is contained in:
Martino Ferrari committed 2026-09-28 02:57:17 +02:00
1 parent 36cd3421f3
commit 0445a1861b
6 files changed
+417 -137

No files matched your search

+59 -28
View File
@@ -12,13 +12,18 @@
#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.
// 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;
@@ -74,22 +79,36 @@ using FrameFn = std::function<void(Plot&, f64 dt)>;
/// **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.
/// 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 `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
/// * 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
/// the same factor before it reaches the controller.
/// that 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.
/// 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
@@ -208,16 +227,21 @@ class PlotArea {
/// `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.
/// 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;
/// 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;
/// 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;
@@ -228,8 +252,11 @@ class PlotArea {
/// 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.
/// 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;
@@ -285,16 +312,20 @@ class PlotArea {
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 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;