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
+416
-136
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_) {
|
||||
// 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_));
|
||||
if (gtk_gl_area_get_error(GTK_GL_AREA(widget_)) == nullptr) renderer_.reset();
|
||||
}
|
||||
// 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;
|
||||
|
||||
+15
-7
@@ -76,7 +76,8 @@ constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
|
||||
constexpr plot::Color kCursorAColor = plot::Color::hex(0xFFB020);
|
||||
constexpr plot::Color kCursorBColor = plot::Color::hex(0x35D0A8);
|
||||
|
||||
/// Logical pixels. The area's framebuffer is `scale_factor` times this.
|
||||
/// Logical pixels. The area's framebuffer -- the area's GL viewport -- is in
|
||||
/// device pixels.
|
||||
constexpr int kWindowWidth = 1200;
|
||||
constexpr int kWindowHeight = 800;
|
||||
/// The size the scripted resize before the exit frame goes to: different on both
|
||||
@@ -304,6 +305,11 @@ void capture_snapshot(Demo& demo) {
|
||||
/// `on_render_after`.
|
||||
gboolean on_teardown(gpointer user_data) {
|
||||
Demo& demo = *static_cast<Demo*>(user_data);
|
||||
// The producer is joined *first*, while the widget and the stream it feeds are
|
||||
// both still alive: the join is what makes "no thread pushes into the stream
|
||||
// after this point" true for the destruction below, and for the frames the
|
||||
// widget may still stage while it is being torn down.
|
||||
demo.producer->stop();
|
||||
// Destroyed explicitly rather than left to the application's shutdown, so an
|
||||
// automated run exercises the widget's teardown -- the renderer's GL objects are
|
||||
// released in `::unrealize`, with the context still current -- and a fault in it
|
||||
@@ -394,10 +400,10 @@ gboolean on_exit_timer(gpointer user_data) {
|
||||
// the strip with it.
|
||||
const bool recomputed = after.axes.w != before.axes.w && after.axes.x == before.axes.x;
|
||||
std::printf("\n[gtk_demo] ---- exit frame ----\n");
|
||||
std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %d\n",
|
||||
std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n",
|
||||
width_before, height_before, gtk_widget_get_width(demo.area->widget()),
|
||||
gtk_widget_get_height(demo.area->widget()), static_cast<unsigned>(viewport.w),
|
||||
static_cast<unsigned>(viewport.h), demo.area->scale_factor());
|
||||
static_cast<unsigned>(viewport.h), static_cast<double>(demo.area->device_scale()));
|
||||
std::printf("[gtk_demo] layout axes %.0fx%.0f at (%.0f,%.0f) -> %.0fx%.0f at (%.0f,%.0f): %s\n",
|
||||
static_cast<double>(before.axes.w), static_cast<double>(before.axes.h),
|
||||
static_cast<double>(before.axes.x), static_cast<double>(before.axes.y),
|
||||
@@ -483,11 +489,12 @@ void on_activate(GtkApplication* app, gpointer user_data) {
|
||||
const plot::Rect axes = std::as_const(p).layout().axes;
|
||||
const plot::Range xr = p.x_range(demo.axis_x);
|
||||
std::printf(
|
||||
"[gtk_demo] t=%.1fs %.3f MS/s %.2f ms/frame scale %d fbo %ux%u axes %.0fx%.0f"
|
||||
"[gtk_demo] t=%.1fs %.3f MS/s %.2f ms/frame scale %g fbo %ux%u axes %.0fx%.0f"
|
||||
" x [%.6f, %.6f] span %.6f auto %d %llu frames\n",
|
||||
demo.meter.elapsed, demo.meter.rate * 1.0e-6,
|
||||
static_cast<double>(demo.area->mean_frame_ms()), demo.area->scale_factor(),
|
||||
static_cast<unsigned>(viewport.w), static_cast<unsigned>(viewport.h),
|
||||
static_cast<double>(demo.area->mean_frame_ms()),
|
||||
static_cast<double>(demo.area->device_scale()), static_cast<unsigned>(viewport.w),
|
||||
static_cast<unsigned>(viewport.h),
|
||||
static_cast<double>(axes.w), static_cast<double>(axes.h), xr.min, xr.max, xr.span(),
|
||||
p.x_axis(demo.axis_x).auto_scale ? 1 : 0,
|
||||
static_cast<unsigned long long>(demo.meter.frames));
|
||||
@@ -592,7 +599,8 @@ int main(int argc, char** argv) {
|
||||
|
||||
// The window is gone by now -- `on_render_after` destroyed it before quitting --
|
||||
// so only what the demo owns is read below.
|
||||
producer.stop(); // no thread pushes into the stream after this point
|
||||
producer.stop(); // already stopped by on_teardown; idempotent, and this is the
|
||||
// path a window closed by hand takes (no on_teardown then)
|
||||
|
||||
print_summary(demo);
|
||||
std::printf("[gtk_demo] teardown the area widget is %s after the main loop\n",
|
||||
|
||||
@@ -57,9 +57,11 @@ endif()
|
||||
# to exist for it to compile.
|
||||
target_sources(plot_tests PRIVATE test_qt_pointer.cpp)
|
||||
|
||||
# Same for the GTK adapter's input conversion (see adapters/gtk4/gtk_input.hpp):
|
||||
# the wheel's unit and sign and the modifier mask are this project's logic, and a
|
||||
# headless X server can only deliver the discrete-wheel branch of it.
|
||||
# Same for the GTK adapter's input translation (see adapters/gtk4/gtk_input.hpp):
|
||||
# the device-pixel mapping it derives from GTK's two sizes, the pixel space its drag
|
||||
# anchor is kept in, the wheel's unit and sign and the modifier mask are this
|
||||
# project's logic, and a headless X server at an integer scale can exercise only
|
||||
# part of it.
|
||||
target_sources(plot_tests PRIVATE test_gtk_input.cpp)
|
||||
target_include_directories(plot_tests PRIVATE "${CMAKE_SOURCE_DIR}/adapters")
|
||||
|
||||
|
||||
+128
-18
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user