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

+150 -17
View File
@@ -1,7 +1,9 @@
#pragma once
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
#include <optional>
@@ -13,22 +15,151 @@ namespace plot::gtk4 {
/// renumbered them would fail to compile rather than silently mis-scale a zoom.
enum class ScrollUnit {
Wheel = 0, ///< the delta is already in wheel detents, one per notch
Surface = 1, ///< the delta is in surface logical pixels
Surface = 1, ///< direct-scroll surface pixels, with no notch size GDK defines
///< (see kSurfacePixelsPerNotch)
};
/// Surface logical pixels per wheel notch, for a `ScrollUnit::Surface` delta.
/// Surface pixels per wheel notch, for a `ScrollUnit::Surface` delta: a
/// **calibration**, not a unit conversion.
///
/// GDK reports a smooth device's scroll in surface pixels, the controller takes
/// notches, and passing a pixel delta through would make a touchpad's zoom
/// unusably sensitive -- a flick is hundreds of pixels, and the controller's one
/// notch is ~14% of the span. 120 pixels per notch is the model Wayland's
/// `wl_pointer.axis_value120` introduced ("the value 120 corresponds to one
/// physical scroll click/detent"), and it is the same 120-per-notch scale a Qt
/// `QWheelEvent::angleDelta()` uses (eighths of a degree, 120 of them to a
/// notch), so a smooth device zooms at the detent rate its discrete siblings do
/// rather than an order of magnitude faster. A delta already reported in wheel
/// units is never divided by this.
inline constexpr f64 kSurfacePixelsPerNotch = 120.0;
/// GDK documents the surface unit as "surface pixels to scroll directly on
/// screen" and defines no notch size for it, so nothing here converts one unit
/// into the other -- how far a smooth device zooms is a choice, and this is where
/// it is made. Its assumption is stated rather than borrowed from an authority:
/// one surface unit is one pixel of a direct-scrolling gesture, and a *deliberate*
/// two-finger gesture on a touchpad is of the order of 100 of them, so 100 surface
/// pixels are taken to be one notch -- the same step one wheel click gives. A
/// gesture of that size therefore zooms by one detent rather than by several,
/// which a smaller value would make it do; a much larger one would put a wheel's
/// own single step out of a touchpad's reach.
///
/// Being a sensitivity and not a conversion, it is uncalibrated against real
/// hardware: the pixels one flick delivers depend on the platform's pixel density
/// and on the gesture itself, and a value measured on a touchpad should replace
/// this one. What is *not* a choice is the unit an event arrives in: GDK reports
/// it per event, and a delta already in wheel units is never divided by this.
inline constexpr f64 kSurfacePixelsPerNotch = 100.0;
// --- the device-pixel mapping ---------------------------------------------
/// The device-pixel mapping of one widget allocation: the factor between the
/// **logical** pixels GTK reports geometry and input in and the **device** pixels
/// the GL area renders into, together with the device viewport itself.
///
/// Derived from the two sizes GTK supplies and from nothing else. The widget's
/// logical size is `gtk_widget_get_width()`/`get_height()`; the device size is
/// what `GtkGLArea::resize` reports, which is the size of the viewport the
/// signal's default handler sets (`glViewport (0, 0, width, height)` in
/// `gtk_gl_area_resize()`) and therefore the only rectangle this adapter's
/// renderer draws into. No scale accessor takes part:
///
/// * `gtk_widget_get_scale_factor()` is documented to return "the next higher
/// integer value" on a system with fractional scaling, so a 1.25 or 1.75
/// display rounds it to 2 -- an input coordinate multiplied by it names a
/// pixel the FBO does not have there;
/// * `gdk_surface_get_scale()` is the surface's real, fractional-capable ratio
/// and the cross-check to read this against, but it describes the *surface*,
/// not the FBO an area was given (GTK 4.22 sizes that FBO as the logical size
/// times the integer factor, i.e. `ceil(scale)`).
///
/// Deriving the factor from the reported device size instead keeps the viewport,
/// the renderer and every forwarded input coordinate on one rectangle by
/// construction: all three use this single value, and a GTK that sized the FBO by
/// some other rule would be followed rather than guessed at.
///
/// A plain value with no toolkit dependency, like the Qt adapter's
/// `PointerAnchor`: the widget owns one and the tests drive it directly.
struct DeviceMapping {
f32 factor = 1.0f; ///< device pixels per logical pixel
Rect viewport{}; ///< the device rectangle, with its origin at (0, 0)
/// From GTK's two sizes: the widget's logical allocation and the device size
/// `::resize` reported. The factor comes off the width -- a widget's device size
/// is its logical size times one ratio, so the two axes cannot disagree, and one
/// factor for both keeps a gesture's dx and dy in one space. `1.0` while there
/// is no logical width to divide by, which is a widget that has not been
/// allocated yet: nothing is drawn into it and no event can reach it.
static DeviceMapping from_sizes(Vec2 logical, Vec2 device) noexcept {
DeviceMapping mapping;
mapping.factor = logical.x > 0.0f ? device.x / logical.x : 1.0f;
mapping.viewport = Rect{0.0f, 0.0f, device.x, device.y};
return mapping;
}
/// A position (or a movement, which is the difference of two of them) in the
/// device pixels `viewport` is measured in. The one place the mapping is
/// applied, so a gesture lands on the pixel the renderer drew.
Vec2 to_device(f32 x, f32 y) const noexcept { return Vec2{x * factor, y * factor}; }
Vec2 to_device(double x, double y) const noexcept {
return to_device(static_cast<f32>(x), static_cast<f32>(y));
}
};
/// The `GtkGestureDrag` anchor: the press point and the last offset the gesture
/// reported, both in **logical** widget pixels.
///
/// Logical on purpose, for the reason the Qt adapter caches its own anchor
/// logically: `GtkGestureDrag` reports `start_x`/`start_y` and `offset_x`/`offset_y`
/// in logical pixels while the factor to device pixels is re-derived on every
/// `::resize`, and that factor changes when the window moves to a monitor with
/// another scale factor (GTK re-emits `::resize` when the widget's scale-factor
/// property changes). An anchor cached in device pixels would compare a position
/// taken at the old factor with one taken at the new one: at 1.0 -> 1.25, a
/// logical press point of 500 would look like a jump of 125 device pixels, and
/// `Controller::pan` applies a delta as a fraction of the range span (clearing
/// auto scale), so a scale change mid-drag would pan the plot by 125 pixels' worth
/// of data. Keeping the anchor logical and scaling the *difference* by the factor
/// in force now makes the same gesture map to the same device distance whatever
/// the scale is.
struct DragAnchor {
bool have = false; ///< false until a drag begins, and again once it ends
Vec2 press{}; ///< logical press point, in widget pixels
Vec2 offset{}; ///< logical offset from `press` at the last sample
/// Begins a drag at the gesture's logical `start` point, with no offset yet.
void begin(f32 start_x, f32 start_y) noexcept {
press = Vec2{start_x, start_y};
offset = Vec2{0.0f, 0.0f};
have = true;
}
/// What a `drag-update` resolves to, in device pixels at the mapping in force.
struct Sample {
Vec2 position; ///< press point plus offset: where the pointer is now
Vec2 delta; ///< movement since the previous sample
};
/// Where the pointer is now, in device pixels at `mapping`: the press point plus
/// the offset of the last sample (`begin()`'s press point, before any update).
Vec2 position(const DeviceMapping& mapping) const noexcept {
return mapping.to_device(press.x + offset.x, press.y + offset.y);
}
/// One `drag-update`: where the pointer is and how far it moved since the
/// previous sample. The offset GDK reports is cached logical, and the movement is
/// a difference of two logical offsets scaled by one factor -- never the
/// difference of two device positions, which is what a factor change between the
/// two samples would corrupt.
Sample update(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
const Vec2 next{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 moved{next.x - offset.x, next.y - offset.y};
offset = next;
return Sample{mapping.to_device(press.x + next.x, press.y + next.y),
mapping.to_device(moved.x, moved.y)};
}
/// Ends the drag with the gesture's final offset, returning the position in
/// device pixels at `mapping`. `drag-end` carries the authoritative end of the
/// gesture -- the controller reads its position to finish a box zoom's selection
/// -- and it may follow the last `drag-update` by however far the pointer moved
/// since, so this offset is the one cached and used, not the last sample's.
Vec2 end(double offset_x, double offset_y, const DeviceMapping& mapping) noexcept {
offset = Vec2{static_cast<f32>(offset_x), static_cast<f32>(offset_y)};
const Vec2 final_position = position(mapping);
have = false;
return final_position;
}
};
/// GTK's `GdkModifierType` bits as the controller's `input::Mod` mask.
///
@@ -76,11 +207,13 @@ inline std::optional<input::Button> button_from(u32 gdk_button) noexcept {
/// A GDK scroll delta in wheel notches, positive = zoom in.
///
/// Two conversions in one place, the same two every adapter has to make:
/// Two steps in one place: the unit a smooth device's delta arrives in, and the
/// sign. The unit is settled per event, exactly as GDK distinguishes it:
///
/// * the *unit* -- a `ScrollUnit::Surface` delta is scaled by
/// `1 / kSurfacePixelsPerNotch` (see there); a `ScrollUnit::Wheel` delta is
/// already one unit per detent;
/// * a `ScrollUnit::Surface` delta is scaled by `1 / kSurfacePixelsPerNotch`,
/// which is a calibration rather than a conversion (see there); a
/// `ScrollUnit::Wheel` delta is already one unit per detent, which is GDK's
/// own definition of that unit;
/// * the *sign* -- GDK's positive `dy` is a scroll south, the wheel pushed away
/// from the user, while the controller's positive amount zooms in, so the
/// value is negated. A wheel notch up arrives as `dy == -1.0` and leaves here