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:
1 parent
36cd3421f3
commit
0445a1861b
6 files changed
+417
-137
No files matched your search
+150
-17
@@ -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
|
||||
|
||||
@@ -91,28 +91,19 @@ u32 gesture_mods(GtkEventController* controller) noexcept {
|
||||
return mods_from(static_cast<u32>(gtk_event_controller_get_current_event_state(controller)));
|
||||
}
|
||||
|
||||
/// The position of the event a controller is handling right now, in device
|
||||
/// pixels. False when there is no event or it has no position -- a key event with
|
||||
/// a scroll controller's anchor is the case that matters.
|
||||
bool current_event_position(GtkEventController* controller, f32 scale, Vec2* out) noexcept {
|
||||
/// The position of the event a controller is handling right now, in device pixels
|
||||
/// at `mapping`. False when there is no event or it has no position -- a key event
|
||||
/// with a scroll controller's anchor is the case that matters.
|
||||
bool current_event_position(GtkEventController* controller, const DeviceMapping& mapping,
|
||||
Vec2* out) noexcept {
|
||||
GdkEvent* event = gtk_event_controller_get_current_event(controller);
|
||||
if (event == nullptr) return false;
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
if (!gdk_event_get_position(event, &x, &y)) return false;
|
||||
*out = Vec2{static_cast<f32>(x) * scale, static_cast<f32>(y) * scale};
|
||||
*out = mapping.to_device(x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The widget's allocation in device pixels: what the plot is given, what GTK
|
||||
/// attaches as the framebuffer, and what `GtkGLArea::resize` reports. Zero-sized
|
||||
/// before the widget is allocated.
|
||||
Rect alloc_in_device(GtkWidget* widget) noexcept {
|
||||
const f32 scale = static_cast<f32>(gtk_widget_get_scale_factor(widget));
|
||||
return Rect{0.0f, 0.0f, static_cast<f32>(gtk_widget_get_width(widget)) * scale,
|
||||
static_cast<f32>(gtk_widget_get_height(widget)) * scale};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PlotArea* PlotArea::create(Plot& plot) noexcept {
|
||||
@@ -190,16 +181,15 @@ PlotArea* PlotArea::create(Plot& plot) noexcept {
|
||||
PlotArea::PlotArea(Plot& plot, GtkWidget* widget) noexcept
|
||||
: plot_(&plot), controller_(plot), widget_(widget) {}
|
||||
|
||||
Rect PlotArea::device_viewport() const noexcept { return alloc_in_device(widget_); }
|
||||
Rect PlotArea::device_viewport() const noexcept { return mapping_.viewport; }
|
||||
|
||||
int PlotArea::scale_factor() const noexcept { return gtk_widget_get_scale_factor(widget_); }
|
||||
f32 PlotArea::device_scale() const noexcept { return mapping_.factor; }
|
||||
|
||||
Vec2 PlotArea::to_device(double x, double y) const noexcept {
|
||||
// The factor `device_viewport()` scales the widget by, applied to a position and
|
||||
// to the offsets derived from two of them: one convention on both sides is what
|
||||
// The mapping `device_viewport()` is the viewport of, applied to a position and
|
||||
// to the offsets derived from two of them: one mapping on both sides is what
|
||||
// makes a gesture land on the data under the cursor.
|
||||
const f32 scale = static_cast<f32>(gtk_widget_get_scale_factor(widget_));
|
||||
return Vec2{static_cast<f32>(x) * scale, static_cast<f32>(y) * scale};
|
||||
return mapping_.to_device(x, y);
|
||||
}
|
||||
|
||||
void PlotArea::refresh_viewport() noexcept {
|
||||
@@ -270,16 +260,22 @@ void PlotArea::realize() noexcept {
|
||||
}
|
||||
|
||||
void PlotArea::unrealize() noexcept {
|
||||
// The context is still alive here and can still be made current (GTK clears it
|
||||
// after this signal), which is what the renderer's RAII handles need: a
|
||||
// `glDelete*` against a context that is going away -- or against no context at
|
||||
// all -- is worse than the leak it would avoid.
|
||||
if (renderer_) {
|
||||
gtk_gl_area_make_current(GTK_GL_AREA(widget_));
|
||||
if (gtk_gl_area_get_error(GTK_GL_AREA(widget_)) == nullptr) renderer_.reset();
|
||||
}
|
||||
// Made current first: the renderer's RAII handles issue `glDelete*` from their
|
||||
// destructors, and GTK clears the context in its own unrealize vfunc, which runs
|
||||
// after this signal's handlers -- so this is the last moment at which those
|
||||
// deletes can name a live context.
|
||||
gtk_gl_area_make_current(GTK_GL_AREA(widget_));
|
||||
// Released unconditionally. The area's error says nothing about whether the
|
||||
// renderer holds names to delete: it is set when context *creation* failed, and
|
||||
// that is the path `realize()` returns from before it builds a renderer, so a
|
||||
// renderer present here has GL objects behind it. Leaving it for the wrapper's
|
||||
// destructor -- which runs at widget finalisation, after GTK has freed the
|
||||
// context -- would issue those deletes with nothing current, which is exactly
|
||||
// what the old error check was trying to avoid. `make_current()` and `reset()`
|
||||
// are no-ops on a widget that never got a context and on an empty renderer.
|
||||
renderer_.reset();
|
||||
press_open_ = false;
|
||||
drag_active_ = false;
|
||||
drag_.have = false;
|
||||
}
|
||||
|
||||
void PlotArea::render() noexcept {
|
||||
@@ -341,16 +337,20 @@ int PlotArea::on_render(GtkGLArea*, GdkGLContext*, void* self) noexcept {
|
||||
}
|
||||
|
||||
void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept {
|
||||
(void)width;
|
||||
(void)height;
|
||||
// Nothing about the size is cached from these two, on purpose: device_viewport()
|
||||
// re-derives it from the live allocation, which is what the input mapping reads
|
||||
// as well. GTK's numbers are the same ones -- it reports the GL viewport in
|
||||
// device pixels, and this area's `width * scale` is that -- but one derivation
|
||||
// is what keeps a gesture, a frame and a screenshot from ever disagreeing.
|
||||
// Refreshing it here means a gesture arriving between the resize and the next
|
||||
// frame maps against the new geometry rather than the old one.
|
||||
static_cast<PlotArea*>(self)->refresh_viewport();
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
// GTK's two sizes, and the one place the mapping is derived from them. `width`
|
||||
// and `height` are the device pixels of the viewport this signal's default
|
||||
// handler is about to set -- `glViewport(0, 0, width, height)` in GTK's own
|
||||
// `gtk_gl_area_resize()` -- and the widget's width and height, which GTK has
|
||||
// already updated when it emits this, are the logical pixels that same rectangle
|
||||
// is reported in. Their ratio is the factor for the viewport *and* for every
|
||||
// forwarded coordinate; refreshing here means a gesture arriving between this
|
||||
// resize and the next frame maps against the new geometry rather than the old.
|
||||
area->mapping_ = DeviceMapping::from_sizes(
|
||||
Vec2{static_cast<f32>(gtk_widget_get_width(area->widget_)),
|
||||
static_cast<f32>(gtk_widget_get_height(area->widget_))},
|
||||
Vec2{static_cast<f32>(width), static_cast<f32>(height)});
|
||||
area->refresh_viewport();
|
||||
}
|
||||
|
||||
// --- pointer input --------------------------------------------------------
|
||||
@@ -425,16 +425,14 @@ void PlotArea::on_click_stopped(GtkGestureClick* gesture, void* self) noexcept {
|
||||
// drag in progress (an exotic cancellation by another widget or a device) closes
|
||||
// the press, which it has to, or the controller would stay in whatever mode the
|
||||
// press began.
|
||||
if (area->press_open_ && !area->drag_active_) area->pointer_release(area->pointer_);
|
||||
if (area->press_open_ && !area->drag_.have) area->pointer_release(area->pointer_);
|
||||
}
|
||||
|
||||
void PlotArea::on_drag_begin(GtkGestureDrag* gesture, double start_x, double start_y,
|
||||
void* self) noexcept {
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
area->drag_active_ = true;
|
||||
area->drag_start_ = area->to_device(start_x, start_y);
|
||||
area->drag_offset_ = Vec2{0.0f, 0.0f};
|
||||
area->pointer_ = area->drag_start_;
|
||||
area->drag_.begin(static_cast<f32>(start_x), static_cast<f32>(start_y));
|
||||
area->pointer_ = area->drag_.position(area->mapping_);
|
||||
area->have_pointer_ = true;
|
||||
|
||||
// GTK emits `drag-begin` before the click gesture's `pressed` (both while the
|
||||
@@ -458,21 +456,20 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o
|
||||
// (see button_from): its motion must not reach the controller as a held-drag
|
||||
// move, which is the one event the controller acts on from its button state.
|
||||
if (!area->press_open_) return;
|
||||
const Vec2 offset = area->to_device(offset_x, offset_y);
|
||||
// The drag gesture reports the offset from the press point, the controller wants
|
||||
// the movement since the previous sample: the difference of two offsets, both
|
||||
// already scaled by the same factor as the viewport and the press.
|
||||
const Vec2 delta = Vec2{offset.x - area->drag_offset_.x, offset.y - area->drag_offset_.y};
|
||||
area->drag_offset_ = offset;
|
||||
const Vec2 position = Vec2{area->drag_start_.x + offset.x, area->drag_start_.y + offset.y};
|
||||
area->pointer_ = position;
|
||||
// The anchor stays logical (see `DragAnchor`): the gesture reports the offset
|
||||
// from the press point in logical pixels, and this is a logical difference
|
||||
// scaled by the factor in force *now*, so a factor change between two samples
|
||||
// cannot turn into a phantom movement of its own.
|
||||
const DragAnchor::Sample sample =
|
||||
area->drag_.update(offset_x, offset_y, area->mapping_);
|
||||
area->pointer_ = sample.position;
|
||||
area->have_pointer_ = true;
|
||||
|
||||
input::PointerEvent e;
|
||||
e.x = position.x;
|
||||
e.y = position.y;
|
||||
e.dx = delta.x;
|
||||
e.dy = delta.y;
|
||||
e.x = sample.position.x;
|
||||
e.y = sample.position.y;
|
||||
e.dx = sample.delta.x;
|
||||
e.dy = sample.delta.y;
|
||||
e.button = area->press_button_;
|
||||
e.mods = area->press_mods_;
|
||||
e.down = true;
|
||||
@@ -484,13 +481,13 @@ void PlotArea::on_drag_end(GtkGestureDrag* gesture, double offset_x, double offs
|
||||
void* self) noexcept {
|
||||
(void)gesture;
|
||||
auto* area = static_cast<PlotArea*>(self);
|
||||
area->drag_active_ = false;
|
||||
// The release carries the authoritative end of the gesture: the controller reads
|
||||
// its position to finish a box zoom's selection, so this is the press point plus
|
||||
// the final offset and not the last update's.
|
||||
const Vec2 offset = area->to_device(offset_x, offset_y);
|
||||
area->pointer_ = Vec2{area->drag_start_.x + offset.x, area->drag_start_.y + offset.y};
|
||||
area->pointer_release(area->pointer_);
|
||||
// the offset `drag-end` itself reports -- which may be past the last update's --
|
||||
// and not the last update's position.
|
||||
const Vec2 position = area->drag_.end(offset_x, offset_y, area->mapping_);
|
||||
area->pointer_ = position;
|
||||
area->pointer_release(position);
|
||||
}
|
||||
|
||||
int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
||||
@@ -508,9 +505,8 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy,
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
const f32 scale = static_cast<f32>(gtk_widget_get_scale_factor(area->widget_));
|
||||
Vec2 at{};
|
||||
if (!current_event_position(GTK_EVENT_CONTROLLER(scroll), scale, &at)) {
|
||||
if (!current_event_position(GTK_EVENT_CONTROLLER(scroll), area->mapping_, &at)) {
|
||||
if (!area->have_pointer_) return FALSE; // no anchor to zoom about
|
||||
at = area->pointer_; // the last position a pointer event reported
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user