Files
plotter/adapters/gtk4/gtk_input.hpp
T
Martino Ferrari 0445a1861b 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.
2026-09-28 02:57:17 +02:00

230 lines
12 KiB
C++

#pragma once
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
#include <plot/math/vec2.hpp>
#include <optional>
namespace plot::gtk4 {
/// GDK's scroll unit (`GdkScrollUnit`), mirrored here so this header -- and a
/// test that drives it -- needs no GTK include directory. `gtk_plot_area.cpp`
/// static-asserts that the two agree with GDK's own enumerators, so a GDK that
/// 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, ///< direct-scroll surface pixels, with no notch size GDK defines
///< (see kSurfacePixelsPerNotch)
};
/// Surface pixels per wheel notch, for a `ScrollUnit::Surface` delta: a
/// **calibration**, not a unit conversion.
///
/// 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.
///
/// A mask, not a reinterpretation: GDK's state carries the *mouse button* bits
/// as well (`GDK_BUTTON1_MASK` is `1 << 8`, and a release reports the button that
/// produced it), so the four modifiers the controller names are the only ones
/// that survive. Without the mask a right-drag would arrive at the controller
/// with an extra `0x400` bit that no other adapter's event carries.
inline u32 mods_from(u32 gdk_state) noexcept {
// The GDK bit values are repeated rather than included: this header names no
// GDK type, and the constants are fixed by GDK's public ABI. `gtk_plot_area.cpp`
// static-asserts them against GDK's own enumerators.
constexpr u32 kGdkShift = 1u << 0;
constexpr u32 kGdkControl = 1u << 2;
constexpr u32 kGdkAlt = 1u << 3;
constexpr u32 kGdkSuper = 1u << 26;
constexpr u32 kGdkMeta = 1u << 28; // maps to Command on macOS, Super here
u32 out = 0;
if ((gdk_state & kGdkShift) != 0u) out |= static_cast<u32>(input::Mod::Shift);
if ((gdk_state & kGdkControl) != 0u) out |= static_cast<u32>(input::Mod::Ctrl);
if ((gdk_state & kGdkAlt) != 0u) out |= static_cast<u32>(input::Mod::Alt);
if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) {
out |= static_cast<u32>(input::Mod::Super);
}
return out;
}
/// The button one of GTK's gestures reports, as the controller's enum.
///
/// Buttons 1, 2 and 3 are the only ones GTK names (left, middle, right) and the
/// only ones the controller has a gesture for. This adapter listens with
/// `gtk_gesture_single_set_button(gesture, 0)`, which GTK documents as "any
/// button", so a press from button 8 or 9 (the navigation buttons) reaches the
/// handler too: `nullopt` is returned for it, and the event is dropped before it
/// can begin a pan.
inline std::optional<input::Button> button_from(u32 gdk_button) noexcept {
switch (gdk_button) {
case 1u: return input::Button::Left;
case 2u: return input::Button::Middle;
case 3u: return input::Button::Right;
default: return std::nullopt;
}
}
/// A GDK scroll delta in wheel notches, positive = zoom in.
///
/// 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:
///
/// * 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
/// as `+1.0`.
///
/// A non-finite delta is returned unchanged: `Controller::on_wheel` refuses it as
/// a strict no-op, which is the one place that decision belongs.
inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept {
if (unit == ScrollUnit::Surface) return -dy / kSurfacePixelsPerNotch;
return -dy;
}
} // namespace plot::gtk4