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

+128 -18
View File
@@ -1,11 +1,13 @@
// The GTK adapter's input translation, driven directly.
//
// Two pieces of it are this project's logic and not a toolkit's, and neither shows
// up in a screenshot: the wheel's unit and sign conversion, and the modifier mask.
// A headless X server delivers discrete wheel notches through XTEST, so the
// surface-pixel branch of `scroll_notches()` has no event to arrive on -- and the
// mask matters because GDK's state carries the mouse-button bits, which would
// otherwise ride into the controller's events.
// Four pieces of it are this project's logic and not a toolkit's, and none of them
// shows up in a screenshot: the device-pixel mapping GTK's two sizes imply, the
// pixel space a drag anchor is kept in, the wheel's unit and sign, and the
// modifier mask. A headless X server delivers discrete wheel notches through XTEST
// (and only at an integer scale), so the surface branch of `scroll_notches()` and
// this session's fractional scales have no event to arrive on -- and the mask
// matters because GDK's state carries the mouse-button bits, which would otherwise
// ride into the controller's events.
//
// `gtk_input.hpp` names no GDK type (it mirrors the two scroll-unit values), so
// this compiles without GTK and without PLOT_BUILD_GTK4.
@@ -18,6 +20,8 @@
using plot::f64;
using plot::u32;
using plot::gtk4::DeviceMapping;
using plot::gtk4::DragAnchor;
using plot::gtk4::ScrollUnit;
using plot::gtk4::button_from;
using plot::gtk4::kSurfacePixelsPerNotch;
@@ -36,23 +40,129 @@ PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) {
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0);
}
// A smooth device reports surface pixels. Passing them through unscaled is the
// defect this pins: a 60-pixel flick is half a notch with the conversion and 60
// notches -- a span multiplied by ~1e-4 -- without it, for a gesture the user
// cannot undo faster than it happens.
PLOT_TEST(gtk_scroll_surface_deltas_become_notches) {
// A smooth device reports surface pixels, and GDK defines no notch size for them
// ("surface pixels to scroll directly on screen" is its whole description), so the
// divisor is this adapter's *calibration* and not a conversion -- see
// kSurfacePixelsPerNotch, and note that it is uncalibrated against real hardware.
// What a test can hold is the reasoning rather than the number: the constant is
// what one notch is, the scaling is linear and unquantised (passing a pixel delta
// through unscaled would make a flick of a few hundred pixels tens of notches, a
// zoom no gesture can undo as fast as it happens), and the value is chosen so that
// a deliberate two-finger gesture -- of the order of 100 surface pixels -- is one
// detent rather than several.
PLOT_TEST(gtk_scroll_surface_deltas_are_scaled_by_the_calibration) {
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Surface), 1.0);
PLOT_CHECK_NEAR(scroll_notches(-2.0 * kSurfacePixelsPerNotch, ScrollUnit::Surface), 2.0);
PLOT_CHECK_NEAR(scroll_notches(-60.0, ScrollUnit::Surface), 0.5);
PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -25.0);
// A smooth device's tail is not quantised away: the conversion is a pure scale,
// so a 0.12-pixel event stays a 0.001-notch zoom (0.015% of the span), exactly as
// the Qt adapter's `angleDelta() / 120` does for a fractional angle. What the
// caller can refuse is an *exactly* zero amount: no zoom, so the event is not this
// widget's (see the adapter's `on_scroll`).
PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -30.0);
// The calibration's own assumption, as an invariant that survives a re-tune: a
// deliberate gesture of 100 px is at most one detent, never several.
PLOT_CHECK(kSurfacePixelsPerNotch >= 100.0);
PLOT_CHECK(scroll_notches(-100.0, ScrollUnit::Surface) <= 1.0 + 1e-9);
// The sign is the wheel's, and a smooth device's tail is not quantised away: the
// scale is a pure division, so a 0.1-pixel event stays a 0.001-notch zoom
// (0.015% of the span). What the caller can refuse is an *exactly* zero amount:
// no zoom, so the event is not this widget's (see the adapter's `on_scroll`).
PLOT_CHECK_NEAR(scroll_notches(+kSurfacePixelsPerNotch, ScrollUnit::Surface), -1.0);
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch / 1000.0, ScrollUnit::Surface), 0.001,
1e-12);
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Surface), 0.0);
// The unit decides which path runs: a delta GDK reported in wheel units is one
// unit per detent however large it is, and is never divided by the calibration.
PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Wheel),
kSurfacePixelsPerNotch);
}
// The device-pixel mapping is derived from GTK's two sizes -- the widget's logical
// allocation and the device size `GtkGLArea::resize` reports for the viewport its
// default handler sets -- and from no scale accessor. The defect this pins:
// `gtk_widget_get_scale_factor()` is documented to return "the next higher integer
// value" under fractional scaling, so an 800-logical-pixel widget whose viewport
// is 1000 device pixels (a 1.25 display) had its input multiplied by 2: a pointer
// at logical x=100 was forwarded as 200, 75 device pixels past where the FBO puts
// it, and `device_viewport()` claimed 1600 of them. The ratio of the two sizes is
// the space the renderer draws into, so it is the one input must use.
PLOT_TEST(gtk_device_mapping_is_the_ratio_of_the_two_sizes) {
const DeviceMapping quarter = DeviceMapping::from_sizes({800.0f, 600.0f}, {1000.0f, 750.0f});
PLOT_CHECK_NEAR(quarter.factor, 1.25, 1e-6);
PLOT_CHECK_NEAR(quarter.viewport.x, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.y, 0.0);
PLOT_CHECK_NEAR(quarter.viewport.w, 1000.0);
PLOT_CHECK_NEAR(quarter.viewport.h, 750.0);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).x, 125.0, 1e-4);
PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).y, 250.0, 1e-4);
const DeviceMapping three_quarters =
DeviceMapping::from_sizes({800.0f, 600.0f}, {1400.0f, 1050.0f});
PLOT_CHECK_NEAR(three_quarters.factor, 1.75, 1e-6);
PLOT_CHECK_NEAR(three_quarters.viewport.w, 1400.0);
PLOT_CHECK_NEAR(three_quarters.viewport.h, 1050.0);
PLOT_CHECK_NEAR(three_quarters.to_device(400.0, 0.0).x, 700.0, 1e-4);
}
// At the integer scales the headless evidence runs at, the derived factor is the
// integer one -- 1600/800 is exactly 2, 800/800 exactly 1 -- so the mapping the
// previous code built from `gtk_widget_get_scale_factor()` is unchanged there, and
// only a fractional scale can tell the two derivations apart. A widget with no
// allocation has no ratio to take: it maps 1:1 and reports the (zero) size GTK
// gave, which is the state before anything is drawn or any event can arrive.
PLOT_TEST(gtk_device_mapping_is_unchanged_at_the_integer_scales) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({800.0f, 600.0f}, {800.0f, 600.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).x, 120.0, 1e-6);
PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).y, 40.0, 1e-6);
const DeviceMapping at_2 = DeviceMapping::from_sizes({800.0f, 600.0f}, {1600.0f, 1200.0f});
PLOT_CHECK(at_2.factor == 2.0f);
PLOT_CHECK_NEAR(at_2.viewport.w, 1600.0);
PLOT_CHECK_NEAR(at_2.to_device(120.0, 40.0).x, 240.0, 1e-6);
const DeviceMapping unallocated = DeviceMapping::from_sizes({0.0f, 0.0f}, {0.0f, 0.0f});
PLOT_CHECK(unallocated.factor == 1.0f);
PLOT_CHECK_NEAR(unallocated.viewport.w, 0.0);
PLOT_CHECK_NEAR(unallocated.viewport.h, 0.0);
}
// The drag anchor stays *logical* while the factor is re-derived per update: GTK
// re-emits `::resize` when the widget's scale-factor property changes, so a drag
// can span a scale change. Cached in device pixels, the same gesture reported
// `20 * 1.25 - 10 * 1.0 = 15` device pixels of movement where the pointer moved
// 12.5, and a position of `100 * 1.0 + 20 * 1.25 = 125` where the FBO puts it at
// 150. `Controller::pan` applies such a delta as a fraction of the range span and
// clears auto scale, so the phantom 2.5 pixels are a real pan of the plot and a
// live axis silently disarmed -- the same defect the Qt adapter's `PointerAnchor`
// pins for `QDpi` changes.
PLOT_TEST(gtk_drag_anchor_stays_logical_across_a_scale_change) {
const DeviceMapping at_1 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1000.0f, 800.0f});
const DeviceMapping at_125 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1250.0f, 1000.0f});
PLOT_CHECK(at_1.factor == 1.0f);
PLOT_CHECK_NEAR(at_125.factor, 1.25, 1e-6);
DragAnchor anchor;
anchor.begin(100.0f, 100.0f);
PLOT_CHECK(anchor.have);
// The press point itself is reported in device pixels at the factor in force.
PLOT_CHECK_NEAR(anchor.position(at_1).x, 100.0, 1e-6);
PLOT_CHECK_NEAR(anchor.position(at_1).y, 100.0, 1e-6);
// First sample, before the move: the pointer moved 10 logical pixels.
const DragAnchor::Sample first = anchor.update(10.0, 0.0, at_1);
PLOT_CHECK_NEAR(first.delta.x, 10.0, 1e-6);
PLOT_CHECK_NEAR(first.position.x, 110.0, 1e-6);
// The window is now on the 1.25 display: the same gesture has moved 10 further
// logical pixels, so the movement is 10 * 1.25 = 12.5 device pixels and the
// position is (100 + 20) * 1.25 = 150, whatever the press was scaled by.
const DragAnchor::Sample second = anchor.update(20.0, 0.0, at_125);
PLOT_CHECK_NEAR(second.delta.x, 12.5, 1e-6);
PLOT_CHECK_NEAR(second.delta.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(second.position.x, 150.0, 1e-6);
// The release is the press point plus the offset `drag-end` reports, at the
// factor in force then -- not the last sample's position, and not an anchor
// cached in device pixels. It may be past the last update's offset, which is why
// `end()` takes its own.
PLOT_CHECK_NEAR(anchor.end(25.0, 0.0, at_125).x, 156.25, 1e-6);
PLOT_CHECK(!anchor.have);
}
// An amount the controller must refuse is not silently turned into a number here: