diff --git a/adapters/gtk4/gtk_input.hpp b/adapters/gtk4/gtk_input.hpp index 6b5efcd..462a7d4 100644 --- a/adapters/gtk4/gtk_input.hpp +++ b/adapters/gtk4/gtk_input.hpp @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include @@ -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(x), static_cast(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(offset_x), static_cast(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(offset_x), static_cast(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 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 diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index aa212c2..7cbce14 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -91,28 +91,19 @@ u32 gesture_mods(GtkEventController* controller) noexcept { return mods_from(static_cast(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(x) * scale, static_cast(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(gtk_widget_get_scale_factor(widget)); - return Rect{0.0f, 0.0f, static_cast(gtk_widget_get_width(widget)) * scale, - static_cast(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(gtk_widget_get_scale_factor(widget_)); - return Vec2{static_cast(x) * scale, static_cast(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(self)->refresh_viewport(); + auto* area = static_cast(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(gtk_widget_get_width(area->widget_)), + static_cast(gtk_widget_get_height(area->widget_))}, + Vec2{static_cast(width), static_cast(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(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(start_x), static_cast(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(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(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 } diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index 0ed33ef..8492dd3 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -12,13 +12,18 @@ #include #include +#include + // 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 ``, 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; /// **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; diff --git a/examples/gtk_demo.cpp b/examples/gtk_demo.cpp index 76d7eb8..13d04d6 100644 --- a/examples/gtk_demo.cpp +++ b/examples/gtk_demo.cpp @@ -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(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(viewport.w), - static_cast(viewport.h), demo.area->scale_factor()); + static_cast(viewport.h), static_cast(demo.area->device_scale())); std::printf("[gtk_demo] layout axes %.0fx%.0f at (%.0f,%.0f) -> %.0fx%.0f at (%.0f,%.0f): %s\n", static_cast(before.axes.w), static_cast(before.axes.h), static_cast(before.axes.x), static_cast(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(demo.area->mean_frame_ms()), demo.area->scale_factor(), - static_cast(viewport.w), static_cast(viewport.h), + static_cast(demo.area->mean_frame_ms()), + static_cast(demo.area->device_scale()), static_cast(viewport.w), + static_cast(viewport.h), static_cast(axes.w), static_cast(axes.h), xr.min, xr.max, xr.span(), p.x_axis(demo.axis_x).auto_scale ? 1 : 0, static_cast(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", diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f613f57..9db3f48 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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") diff --git a/tests/test_gtk_input.cpp b/tests/test_gtk_input.cpp index 1f3bb80..5f79530 100644 --- a/tests/test_gtk_input.cpp +++ b/tests/test_gtk_input.cpp @@ -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: