// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include #include namespace plot::gtk4 { /// GDK's scroll unit (`GdkScrollUnit`), mirrored here so this header -- and a /// test that drives it -- needs no GTK include directory. `gtk_plot_area.cpp` /// static-asserts that the two agree with GDK's own enumerators, so a GDK that /// renumbered them would fail to compile rather than silently mis-scale a zoom. enum class ScrollUnit { Wheel = 0, ///< the delta is already in wheel detents, one per notch Surface = 1, ///< direct-scroll surface pixels, with no notch size GDK defines ///< (see kSurfacePixelsPerNotch) }; /// Surface pixels per wheel notch, for a `ScrollUnit::Surface` delta: a /// **calibration**, not a unit conversion. /// /// GDK documents the surface unit as "surface pixels to scroll directly on /// screen" and defines no notch size for it, so nothing here converts one unit /// into the other -- how far a smooth device zooms is a choice, and this is where /// it is made. Its assumption is stated rather than borrowed from an authority: /// one surface unit is one pixel of a direct-scrolling gesture, and a *deliberate* /// two-finger gesture on a touchpad is of the order of 100 of them, so 100 surface /// pixels are taken to be one notch -- the same step one wheel click gives. A /// gesture of that size therefore zooms by one detent rather than by several, /// which a smaller value would make it do; a much larger one would put a wheel's /// own single step out of a touchpad's reach. /// /// Being a sensitivity and not a conversion, it is uncalibrated against real /// hardware: the pixels one flick delivers depend on the platform's pixel density /// and on the gesture itself, and a value measured on a touchpad should replace /// this one. What is *not* a choice is the unit an event arrives in: GDK reports /// it per event, and a delta already in wheel units is never divided by this. inline constexpr f64 kSurfacePixelsPerNotch = 100.0; // --- the device-pixel mapping --------------------------------------------- /// The device-pixel mapping of one widget allocation: the factor between the /// **logical** pixels GTK reports geometry and input in and the **device** pixels /// the GL area renders into, together with the device viewport itself. /// /// Derived from the two sizes GTK supplies and from nothing else. The widget's /// logical size is `gtk_widget_get_width()`/`get_height()`; the device size is /// what `GtkGLArea::resize` reports, which is the size of the viewport the /// signal's default handler sets (`glViewport (0, 0, width, height)` in /// `gtk_gl_area_resize()`) and therefore the only rectangle this adapter's /// renderer draws into. No scale accessor takes part: /// /// * `gtk_widget_get_scale_factor()` is documented to return "the next higher /// integer value" on a system with fractional scaling, so a 1.25 or 1.75 /// display rounds it to 2 -- an input coordinate multiplied by it names a /// pixel the FBO does not have there; /// * `gdk_surface_get_scale()` is the surface's real, fractional-capable ratio /// and the cross-check to read this against, but it describes the *surface*, /// not the FBO an area was given (GTK 4.22 sizes that FBO as the logical size /// times the integer factor, i.e. `ceil(scale)`). /// /// Deriving the factor from the reported device size instead keeps the viewport, /// the renderer and every forwarded input coordinate on one rectangle by /// construction: all three use this single value, and a GTK that sized the FBO by /// some other rule would be followed rather than guessed at. /// /// A plain value with no toolkit dependency, like the Qt adapter's /// `PointerAnchor`: the widget owns one and the tests drive it directly. struct DeviceMapping { f32 factor = 1.0f; ///< device pixels per logical pixel Rect viewport{}; ///< the device rectangle, with its origin at (0, 0) /// From GTK's two sizes: the widget's logical allocation and the device size /// `::resize` reported. The factor comes off the width -- a widget's device size /// is its logical size times one ratio, so the two axes cannot disagree, and one /// factor for both keeps a gesture's dx and dy in one space. `1.0` while there /// is no logical width to divide by, which is a widget that has not been /// allocated yet: nothing is drawn into it and no event can reach it. static DeviceMapping from_sizes(Vec2 logical, Vec2 device) noexcept { DeviceMapping mapping; mapping.factor = logical.x > 0.0f ? device.x / logical.x : 1.0f; mapping.viewport = Rect{0.0f, 0.0f, device.x, device.y}; return mapping; } /// A position (or a movement, which is the difference of two of them) in the /// device pixels `viewport` is measured in. The one place the mapping is /// applied, so a gesture lands on the pixel the renderer drew. Vec2 to_device(f32 x, f32 y) const noexcept { return Vec2{x * factor, y * factor}; } Vec2 to_device(double x, double y) const noexcept { return to_device(static_cast(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. /// /// A mask, not a reinterpretation: GDK's state carries the *mouse button* bits /// as well (`GDK_BUTTON1_MASK` is `1 << 8`, and a release reports the button that /// produced it), so the four modifiers the controller names are the only ones /// that survive. Without the mask a right-drag would arrive at the controller /// with an extra `0x400` bit that no other adapter's event carries. inline u32 mods_from(u32 gdk_state) noexcept { // The GDK bit values are repeated rather than included: this header names no // GDK type, and the constants are fixed by GDK's public ABI. `gtk_plot_area.cpp` // static-asserts them against GDK's own enumerators. constexpr u32 kGdkShift = 1u << 0; constexpr u32 kGdkControl = 1u << 2; constexpr u32 kGdkAlt = 1u << 3; constexpr u32 kGdkSuper = 1u << 26; constexpr u32 kGdkMeta = 1u << 28; // maps to Command on macOS, Super here u32 out = 0; if ((gdk_state & kGdkShift) != 0u) out |= static_cast(input::Mod::Shift); if ((gdk_state & kGdkControl) != 0u) out |= static_cast(input::Mod::Ctrl); if ((gdk_state & kGdkAlt) != 0u) out |= static_cast(input::Mod::Alt); if ((gdk_state & kGdkSuper) != 0u || (gdk_state & kGdkMeta) != 0u) { out |= static_cast(input::Mod::Super); } return out; } /// The button one of GTK's gestures reports, as the controller's enum. /// /// Buttons 1, 2 and 3 are the only ones GTK names (left, middle, right) and the /// only ones the controller has a gesture for. This adapter listens with /// `gtk_gesture_single_set_button(gesture, 0)`, which GTK documents as "any /// button", so a press from button 8 or 9 (the navigation buttons) reaches the /// handler too: `nullopt` is returned for it, and the event is dropped before it /// can begin a pan. inline std::optional button_from(u32 gdk_button) noexcept { switch (gdk_button) { case 1u: return input::Button::Left; case 2u: return input::Button::Middle; case 3u: return input::Button::Right; default: return std::nullopt; } } /// A GDK scroll delta in wheel notches, positive = zoom in. /// /// Two steps in one place: the unit a smooth device's delta arrives in, and the /// sign. The unit is settled per event, exactly as GDK distinguishes it: /// /// * a `ScrollUnit::Surface` delta is scaled by `1 / kSurfacePixelsPerNotch`, /// which is a calibration rather than a conversion (see there); a /// `ScrollUnit::Wheel` delta is already one unit per detent, which is GDK's /// own definition of that unit; /// * the *sign* -- GDK's positive `dy` is a scroll south, the wheel pushed away /// from the user, while the controller's positive amount zooms in, so the /// value is negated. A wheel notch up arrives as `dy == -1.0` and leaves here /// as `+1.0`. /// /// A non-finite delta is returned unchanged: `Controller::on_wheel` refuses it as /// a strict no-op, which is the one place that decision belongs. inline f64 scroll_notches(f64 dy, ScrollUnit unit) noexcept { if (unit == ScrollUnit::Surface) return -dy / kSurfacePixelsPerNotch; return -dy; } /// The keyvals this adapter maps, as GDK's own numbers, mirrored here so the /// mapping below -- and a test that drives it -- needs no GTK include directory. /// `gtk_plot_area.cpp` static-asserts each against GDK's own macro. /// /// These are the *function* keyvals (the `0xFF00` page), fixed by X11's keysym /// convention GDK inherits; a keypad arrow (a keypad key with Num Lock off) is a /// different keyval and deliberately unmapped, exactly as the GLFW adapter maps /// the main-row arrows and not `GLFW_KEY_KP_4`. constexpr u32 kGdkKeyEscape = 0xFF1Bu; constexpr u32 kGdkKeyLeft = 0xFF51u; constexpr u32 kGdkKeyUp = 0xFF52u; constexpr u32 kGdkKeyRight = 0xFF53u; constexpr u32 kGdkKeyDown = 0xFF54u; /// One key-press translation, by keyval first and Unicode fallback second. /// /// GTK names a key by its keyval, which is layout- and level-dependent: `+` is /// usually shift+`=` and arrives as a different keyval, but both decode to the /// same Unicode code point, which is why the printable keys go through /// `gdk_keyval_to_unicode()`. The function keys decode to no code point at all, /// so they are matched on the keyval itself, before that fallback. `0` means "no /// plot key": the caller returns the event to GTK unhandled. /// /// The keypad's plus and minus decode to the same code points as the main row's, /// so they come along for free -- the same set the GLFW adapter maps with /// `GLFW_KEY_KP_ADD`/`GLFW_KEY_KP_SUBTRACT`. inline int key_from(u32 keyval, u32 unicode) noexcept { switch (keyval) { case kGdkKeyEscape: return input::kKeyEscape; case kGdkKeyLeft: return input::kKeyLeft; case kGdkKeyRight: return input::kKeyRight; case kGdkKeyUp: return input::kKeyUp; case kGdkKeyDown: return input::kKeyDown; default: break; } switch (unicode) { case static_cast(input::kKeyZoomIn): case static_cast('='): return input::kKeyZoomIn; case static_cast(input::kKeyZoomOut): case static_cast('_'): return input::kKeyZoomOut; case static_cast(input::kKeyFit): case static_cast(input::kKeyFitUpper): return input::kKeyFit; default: return 0; } } /// The state one `GtkGestureZoom` is folded into `input::GestureEvent`s from. /// /// `GtkGestureZoom` reports a **cumulative** scale (`gtk_gesture_zoom_get_scale_delta()` /// and the `scale-changed` argument are the zoom since the gesture began, 1.0 at /// the start) and knows the focal point only as the bounding box of the pointer /// the gesture holds (`gtk_gesture_bounding_box_center()`), also cumulative in the /// sense that it is where the fingers are *now*. The controller wants the /// increments -- `GestureEvent::scale` is this frame's zoom factor about the focal /// point, 1.0 = no change, and `(dx, dy)` the focal point's movement since the /// previous gesture event -- so the area owns one of these and folds each /// `scale-changed` exactly like the Qt adapter folds its pinch (`PinchTracker`). /// /// The focal point is cached in **logical** widget pixels, like `DragAnchor` above, /// for the same reason: the mapping's factor is re-derived on every `::resize`, /// and a cache in device pixels would compare two points taken at different /// factors. The difference of two logical points, scaled by the factor in force /// now, is the movement the fingers actually made. /// /// A plain value with no toolkit dependency, like every other type here: the area /// owns one, and the tests drive it directly. struct ZoomAnchor { bool active = false; ///< true between the gesture's begin and its end f32 last_scale = 1.0f; ///< the cumulative scale the previous update folded f32 x = 0.0f; ///< last focal point, logical widget pixels f32 y = 0.0f; /// Starts a gesture at its first cumulative `scale` and logical focal point. /// Records only: a zoom that just began has zoomed and moved nothing yet. void begin(f32 scale, Vec2 logical_center) noexcept { active = true; last_scale = scale; x = logical_center.x; y = logical_center.y; } /// Folds one `scale-changed` into an incremental `GestureEvent`, in device /// pixels at `mapping`: the zoom about the focal point, and the focal point's /// movement since the previous update. A no-op before `begin()`, so a /// `scale-changed` that arrives without a begin -- a host that missed the start /// -- zooms nothing rather than zooming from a made-up anchor. input::GestureEvent update(f32 scale, Vec2 logical_center, const DeviceMapping& mapping) noexcept { input::GestureEvent g; if (!active) return g; // This frame's zoom: the cumulative ratio over the one last folded. The first // update after begin() divides the scale by itself -- 1.0, no zoom, as it // should be when the fingers have not moved apart yet. g.scale = last_scale != 0.0f ? scale / last_scale : 1.0f; const Vec2 device = mapping.to_device(logical_center.x, logical_center.y); g.x = device.x; g.y = device.y; const Vec2 moved{logical_center.x - x, logical_center.y - y}; const Vec2 device_moved = mapping.to_device(moved.x, moved.y); g.dx = device_moved.x; g.dy = device_moved.y; last_scale = scale; x = logical_center.x; y = logical_center.y; return g; } /// Ends the gesture: a straggler update after this zooms nothing. void end() noexcept { active = false; } }; } // namespace plot::gtk4