// 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; } } // namespace plot::gtk4