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.
212 lines
12 KiB
C++
212 lines
12 KiB
C++
// The GTK adapter's input translation, driven directly.
|
|
//
|
|
// 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.
|
|
#include "harness.hpp"
|
|
|
|
#include <gtk4/gtk_input.hpp>
|
|
|
|
#include <cmath>
|
|
#include <optional>
|
|
|
|
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;
|
|
using plot::gtk4::mods_from;
|
|
using plot::gtk4::scroll_notches;
|
|
|
|
// GDK's positive `dy` is a scroll *south* (the wheel pushed away from the user) and
|
|
// a `ScrollUnit::Wheel` delta is already one unit per detent, so a notch up --
|
|
// `dy == -1.0`, what an X11 button-4 press arrives as -- has to leave as `+1.0`.
|
|
// Getting the sign wrong would invert every zoom, which looks like a working plot
|
|
// with an upside-down wheel.
|
|
PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) {
|
|
PLOT_CHECK_NEAR(scroll_notches(-1.0, ScrollUnit::Wheel), 1.0);
|
|
PLOT_CHECK_NEAR(scroll_notches(1.0, ScrollUnit::Wheel), -1.0);
|
|
PLOT_CHECK_NEAR(scroll_notches(-3.0, ScrollUnit::Wheel), 3.0);
|
|
PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0);
|
|
}
|
|
|
|
// 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(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:
|
|
// `Controller::on_wheel` rejects a non-finite delta as a strict no-op, and that
|
|
// decision belongs to it alone.
|
|
PLOT_TEST(gtk_scroll_passes_a_non_finite_delta_through) {
|
|
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Wheel)));
|
|
PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Surface)));
|
|
}
|
|
|
|
// GDK reports the button bits inside the same state word as the modifiers -- a
|
|
// release arrives with `GDK_BUTTON1_MASK` (1 << 8) set, a right-drag with `1 << 10`
|
|
// -- so the translation is a mask. Without it every drag's events would carry bits
|
|
// no other adapter's do.
|
|
PLOT_TEST(gtk_mods_ignore_the_mouse_button_bits) {
|
|
PLOT_CHECK(mods_from(0x100u) == 0u); // GDK_BUTTON1_MASK
|
|
PLOT_CHECK(mods_from(0x400u) == 0u); // GDK_BUTTON3_MASK
|
|
PLOT_CHECK(mods_from(0x501u) == static_cast<u32>(plot::input::Mod::Shift));
|
|
|
|
const u32 shift = static_cast<u32>(plot::input::Mod::Shift);
|
|
const u32 ctrl = static_cast<u32>(plot::input::Mod::Ctrl);
|
|
const u32 alt = static_cast<u32>(plot::input::Mod::Alt);
|
|
const u32 super = static_cast<u32>(plot::input::Mod::Super);
|
|
|
|
PLOT_CHECK(mods_from(1u) == shift); // GDK_SHIFT_MASK
|
|
PLOT_CHECK(mods_from(4u) == ctrl); // GDK_CONTROL_MASK
|
|
PLOT_CHECK(mods_from(8u) == alt); // GDK_ALT_MASK
|
|
PLOT_CHECK(mods_from(1u << 26) == super); // GDK_SUPER_MASK
|
|
PLOT_CHECK(mods_from(1u << 28) == super); // GDK_META_MASK, Command on macOS
|
|
PLOT_CHECK(mods_from(1u | 4u | 8u | (1u << 26)) == (shift | ctrl | alt | super));
|
|
// GDK_LOCK_MASK (CapsLock) is not a modifier the controller names.
|
|
PLOT_CHECK(mods_from(2u) == 0u);
|
|
}
|
|
|
|
// The gestures listen to "any button", so the numbers that reach the mapping are
|
|
// 1, 2, 3 and whatever the device reports. Only the three the controller has a
|
|
// gesture for are accepted: an unhandled button is dropped rather than defaulting
|
|
// to a left-drag pan.
|
|
PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) {
|
|
PLOT_CHECK(button_from(1u) == std::optional<plot::input::Button>(plot::input::Button::Left));
|
|
PLOT_CHECK(button_from(2u) == std::optional<plot::input::Button>(plot::input::Button::Middle));
|
|
PLOT_CHECK(button_from(3u) == std::optional<plot::input::Button>(plot::input::Button::Right));
|
|
PLOT_CHECK(!button_from(0u).has_value());
|
|
PLOT_CHECK(!button_from(8u).has_value()); // a navigation button
|
|
PLOT_CHECK(!button_from(9u).has_value());
|
|
}
|